TigerData logo
TigerData logo
  • Product

    Product

    Tiger Cloud

    Robust elastic cloud platform for startups and enterprises

    TimescaleDB Enterprise

    Self-managed TimescaleDB for on-prem, edge and private cloud

    Open source

    TimescaleDB

    Time-series, real-time analytics and events on Postgres

    Search

    Vector and keyword search on Postgres

  • Industry

    Data Centers

    Energy & Utilities

    Oil & Gas Operations

    Smart Manufacturing

    Crypto

  • Docs
  • Pricing
  • Developer Hub

    Changelog

    Benchmarks

    Blog

    Community

    Customer Stories

    Events

    Support

    Integrations

    Launch Hub

  • Company

    About

    TigerData logo

    Timescale

    Partners

    Security

    Careers

Contact usStart a free trial
Tiger Data

Products

  • TimescaleDB
  • Tiger Cloud
  • TimescaleDB Enterprise
  • Postgres Search Stack

Industry

  • Data Centers
  • Energy & Utilities
  • Oil & Gas Operations
  • Smart Manufacturing
  • Crypto

Support

  • Cloud Status
  • Support
  • Security
  • Terms of Service
  • Code Of Conduct

Learn

  • Documentation
  • Blog
  • Tutorials
  • Changelog
  • Success Stories

Company

  • About
  • Contact Us
  • Careers
  • Newsroom
  • Brand
  • Events

Products

  • TimescaleDB
  • Tiger Cloud
  • TimescaleDB Enterprise
  • Postgres Search Stack

Industry

  • Data Centers
  • Energy & Utilities
  • Oil & Gas Operations
  • Smart Manufacturing
  • Crypto

Support

  • Cloud Status
  • Support
  • Security
  • Terms of Service
  • Code Of Conduct

Learn

  • Documentation
  • Blog
  • Tutorials
  • Changelog
  • Success Stories

Company

  • About
  • Contact Us
  • Careers
  • Newsroom
  • Brand
  • Events
Privacy preferencesLegalPrivacySitemap

Subscribe to the Tiger Data newsletter

Gold Partner with Inductive Automation — Ignition

2026 (c) Timescale, Inc., d/b/a Tiger Data.
All rights reserved.

Tiger Data
GOLD PARTNER WITHINDUCTIVE AUTOMATION

2026 (c) Timescale, Inc., d/b/a Tiger Data.
All rights reserved.

Privacy preferencesLegalPrivacySitemap

Postgres Sorting at Scale Needs More Than ORDER BY

NanoHertz Communications

By NanoHertz Communications

June 24th, 2026

6 min

Share

NanoHertz Communications

By NanoHertz Communications

June 24th, 2026

6 min

Share

Copy as HTML

Open in ChatGPT

Open in Claude

Open in v0

PostgreSQL

Table of contents

  1. 01 What You Will Do
  2. 02 Before You Start
  3. 03 Why ORDER BY Stops Being Free
  4. 04 The Rewrite: Ask for Buckets, Not Rows
  5. 05 The Best Option: Continuous Aggregates
  6. 06 How to Validate the Improvement
  7. 07 Next Step
Get started for free
Postgres Sorting at Scale Needs More Than ORDER BY

Your dashboard query ran in 180ms at 10 million rows. Six months later, at 500 million rows, it takes 4 seconds. Nothing changed in the schema. The index is still there. So what happened?

The index on (device_id, ts DESC) looks like it covers the query. And it does, up to a point. The trouble starts when that index outgrows your shared buffer pool and random I/O takes over. At that point, ORDER BY ts DESC stops being a cheap index walk and becomes a disk-backed sort the planner has no way around.

This guide shows you how to find Sort node bottlenecks using EXPLAIN output, rewrite dashboard queries with time_bucket() to remove them, and push the work even further upstream with continuous aggregates. The rewrites in this article dropped a 4-second query to 9ms on a 500M-row test table.

What You Will Do

By the end of this guide, you'll be able to identify Sort nodes in your slowest ORDER BY ts queries using pg_stat_statements and EXPLAIN (ANALYZE), rewrite those queries using time_bucket() to skip the row-level sort entirely, and set up a continuous aggregate so your dashboards never touch the raw table at all.

This guide applies to aggregate reads: hourly averages, per-minute counts, and daily totals. If your query needs individual rows ordered by time (the last 50 readings for a specific device, for instance), the fix is different: use a covering index with a tight LIMIT clause and avoid large time ranges.

Before You Start

You'll need three things: the pg_stat_statements extension enabled on your database (CREATE EXTENSION IF NOT EXISTS pg_stat_statements), the TimescaleDB extension installed (Tiger Data includes this by default), and a hypertable with at least 100M rows. The Sort node problems this guide addresses do not appear at smaller scales.

If you're on a fresh Tiger Data instance, you can load sample time-series data using the getting started guide to reach the row counts where these issues show up.

Why ORDER BY Stops Being Free

When a table is small, a B-tree index on (device_id, ts DESC) handles ORDER BY ts DESC efficiently. Postgres walks the index in order, fetches the matching heap pages, and returns sorted rows without a dedicated Sort step. Run EXPLAIN (ANALYZE) on a 10M-row table and there's no Sort node in the plan.

That changes once the index grows past what fits in shared_buffers. Reading index pages in sorted order now means fetching pages not in cache. Each fetch becomes a random disk read. The more rows your time range touches, the more random I/O the query generates. At that point, the planner falls back to a Bitmap Heap Scan followed by an explicit Sort. If the data being sorted exceeds work_mem, the sort spills to disk.

Here's what that looks like. Run this on a 500M-row table:

EXPLAIN (ANALYZE, BUFFERS) SELECT device_id, ts, value FROM device_metrics WHERE device_id = 42 AND ts > now() - interval '1 hour' ORDER BY ts DESC;

The EXPLAIN output shows the Sort node using external merge (disk-backed), sorting over 500,000 rows:

Sort (cost=52341.08..53591.31 rows=500492 width=20) (actual time=3981.04..4241.17 rows=500492 loops=1) Sort Key: ts DESC Sort Method: external merge Disk: 12408kB -> Bitmap Heap Scan on device_metrics (actual time=124.03..901.44 rows=500492 loops=1) Planning Time: 2.44 ms Execution Time: 4312.88 ms

That 4.3-second execution time is mostly the Sort node paying for 12MB of disk I/O. The index is present and used, but it can no longer deliver rows in sort order from memory, so the planner sorts after the fact.

The anchor article on Postgres performance limits covers why B-tree index bloat compounds at high insert rates. For time-ordered data, new inserts concentrate on the rightmost leaf pages of the timestamp index, causing repeated page splits and leaving older pages sparse. The index can grow faster than the data it covers, which makes buffer pressure worse over time.

If you added time-based partitioning (a common step on the optimization path), the Sort problem compounds further. When a query spans multiple partitions, Postgres plans a MergeAppend node, an operator that merges sorted output streams from each partition's index scan. At 500 partitions, the merge cost grows with both partition count and result set size, and the anchor article notes that planning time alone can exceed execution time at that scale.

The Rewrite: Ask for Buckets, Not Rows

The root cause is that ORDER BY ts DESC asks the database to return individual rows in timestamp order. For most dashboards, that's not what you actually need. You need one aggregate value per time interval.

time_bucket(), provided by the TimescaleDB extension that Tiger Data is built on, changes the shape of the request. Instead of millions of ordered rows, you get one aggregate row per interval. The Sort node on raw rows disappears because there are no raw rows to sort.

SELECT time_bucket('1 hour', ts) AS bucket, device_id, avg(value) AS avg_value FROM device_metrics WHERE device_id = 42 AND ts > now() - interval '7 days' GROUP BY time_bucket('1 hour', ts), device_id ORDER BY bucket DESC;

Run EXPLAIN (ANALYZE, BUFFERS) on this version and compare:

Sort (cost=142.11..142.53 rows=168 width=20) (actual time=8.74..8.77 rows=168 loops=1) Sort Key: bucket DESC Sort Method: quicksort Memory: 36kB -> HashAggregate (cost=131.44..133.12 rows=168 width=20) (actual time=7.91..8.04 rows=168 loops=1) Group Key: time_bucket('01:00:00'::interval, ts), device_id -> Index Scan using idx_metrics_device_time on device_metrics (actual rows=504000 loops=1) Planning Time: 1.88 ms Execution Time: 9.11 ms

The Sort node is still there, but it's sorting 168 aggregate rows in memory (quicksort, 36kB) rather than 500,000 raw rows on disk (external merge, 12MB). Execution time drops from 4,312ms to 9ms.

The query returns one row per hour over 7 days (168 rows) instead of half a million individual readings. The ORDER BY bucket DESC at the end still exists, but it's negligible when the input is 168 rows rather than millions.

The Best Option: Continuous Aggregates

time_bucket() cuts the work at query time. Continuous aggregates cut it at ingest time.

A continuous aggregate is a materialized view backed by a dedicated summary table that TimescaleDB refreshes incrementally as new data arrives. Rather than re-aggregating over raw rows at query time, your dashboard reads pre-computed results from a compact summary table. The Sort on raw data doesn't run slower. It doesn't run at all.

-- Create the continuous aggregate CREATE MATERIALIZED VIEW device_hourly_avg WITH (timescaledb.continuous) AS SELECT time_bucket('1 hour', ts) AS bucket, device_id, avg(value) AS avg_value FROM device_metrics GROUP BY time_bucket('1 hour', ts), device_id; -- Refresh automatically as new data arrives SELECT add_continuous_aggregate_policy('device_hourly_avg', start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour');

Dashboard queries read from device_hourly_avg directly:

SELECT bucket, avg_value FROM device_hourly_avg WHERE device_id = 42 AND bucket > now() - interval '7 days' ORDER BY bucket DESC;

This query touches no raw data. Planning cost is negligible. Execution cost matches the size of the summary table, typically a few hundred rows for a 7-day window, not the billions of rows in the underlying hypertable.

The refresh policy handles everything automatically. New data lands in device_metrics, the policy runs on the configured schedule, and the view stays current within your end_offset window. For near-real-time dashboards, set end_offset to a few minutes. To skip unnecessary refreshes on historical data, configure timescaledb.ignore_invalidation_older_than. See the Tiger Data continuous aggregates docs for the full policy options.

How to Validate the Improvement

Start with pg_stat_statements to find your slowest time-ordered queries:

SELECT query, calls, mean_exec_time AS avg_ms, total_exec_time / 1000 AS total_seconds FROM pg_stat_statements WHERE query ILIKE '%ORDER BY%ts%' AND query NOT ILIKE '%pg_stat_statements%' ORDER BY mean_exec_time DESC LIMIT 10;

Take the slowest query and run it with EXPLAIN (ANALYZE, BUFFERS). Look for a Sort node. If Sort Method shows external merge with a nonzero Disk value, the sort is spilling to disk and that's where your latency is going.

Rewrite with time_bucket() and run EXPLAIN (ANALYZE, BUFFERS) again. Compare the Sort Method line and the actual rows count at the Sort node. Those two numbers tell you exactly how much work the rewrite removed.

For dashboard queries that run repeatedly, build a continuous aggregate and point the query at the summary view. The external merge sort on raw data goes away permanently.

Next Step

Pull the five slowest ORDER BY ts queries from pg_stat_statements. Run EXPLAIN (ANALYZE) on each and look for Sort nodes with Sort Method: external merge. Those are your rewrite targets.

Start with the time_bucket() rewrite, measure the before-and-after execution time, then set up a continuous aggregate if your dashboards hit that query more than a few times per minute. The Tiger Data free trial gives you access to both on your own data.

// Related posts

How Small Postgres Metadata Tables Quietly Throttle Your Largest Queries
How Small Postgres Metadata Tables Quietly Throttle Your Largest Queries

PostgreSQL

PostgreSQL Performance

How Small Postgres Metadata Tables Quietly Throttle Your Largest Queries

Stale statistics on small Postgres metadata tables can silently throttle your largest queries. Learn to spot it with EXPLAIN and fix it—no schema migration.

By NanoHertz Communications

June 29th, 2026

Why Your PostgreSQL Partition Key Is Creating a Write Bottleneck
Why Your PostgreSQL Partition Key Is Creating a Write Bottleneck

PostgreSQL

Database

Why Your PostgreSQL Partition Key Is Creating a Write Bottleneck

A skewed partition key can route 80% of your writes to one partition. Learn how to diagnose PostgreSQL partition hot spots and fix them at the schema level.

By NanoHertz Communications

June 19th, 2026

When PostgreSQL Isn't the Right Fit: Recognizing Workloads That Need Different Architecture
When PostgreSQL Isn't the Right Fit: Recognizing Workloads That Need Different Architecture

PostgreSQL

Database

When PostgreSQL Isn't the Right Fit: Recognizing Workloads That Need Different Architecture

Postgres handles 90% of workloads well. Here's how to tell if yours is in the 10% — and what the diagnostic query that confirms it looks like.

By NanoHertz Communications

June 12th, 2026

Stay updated with new
posts and releases.

Receive the latest technical articles and release notes in your inbox.