PostgreSQL EXPLAIN
Incremental Sort in PostgreSQL EXPLAIN
An Incremental Sort takes rows that are already sorted on the first part of the sort key and sorts each group of equal values on the rest. With a LIMIT it can stop after a few groups instead of sorting everything. It appeared in PostgreSQL 13.
Updated 9 October 2026
What it does
Say you ask for ORDER BY customer_id, created_at and there’s an index on customer_id. The index
already returns rows sorted by customer_id; only the order within each customer is missing. An
Incremental Sort reads rows from the index, collects a group with the same customer_id, sorts that
group by created_at, returns it, and moves on.
Each sort is small, so it uses little memory, and the first rows come out almost immediately. With a
LIMIT above it, it stops as soon as it has enough rows, without reading the rest of the input.
It was added in PostgreSQL 13. Since 16 it’s also used for DISTINCT, and since 18 under merge joins.
enable_incremental_sort (on by default) switches it off for testing.
When the planner picks it
ORDER BY a, bwhere an index or an earlier step already provides order ona.- Especially with
LIMIT: a full Sort would have to read every row first. - Under window functions (
PARTITION BY a ORDER BY b) and aggregates when the input is sorted on the leading column.
Reading its numbers
Incremental Sort (cost=1.89..87206.24 rows=1000000 width=34) (actual time=0.665..1.355 rows=100.00 loops=1)
Sort Key: customer_id, created_at
Presorted Key: customer_id
Full-sort Groups: 3 Sort Method: quicksort Average Memory: 27kB Peak Memory: 27kB
Sort Key: the full order requested.Presorted Key: the leading columns that arrive already sorted.Full-sort Groups: batches it sorted on the whole sort key. When groups are small, it gathers rows from several groups into one batch (here three batches covered the 101 rows it read) and sorts them together.Pre-sorted Groups: when one group turns out to be large, it switches to sorting that group on the remaining columns only. This line appears only if that happened, for example:Pre-sorted Groups: 2 Sort Methods: top-N heapsort, quicksort Average Memory: 189kB Peak Memory: 189kB.Sort Method,Average Memory,Peak Memory: per batch. If a batch spills you’ll seeDiskfigures too.- The child’s
rows: how much input it actually read.rows=101.00under aLIMIT 100means it read one row past the limit to be sure the last group was complete.
When it’s a problem
It helps when groups are small. It doesn’t when the presorted column has few distinct values:
- Huge groups. With
ORDER BY status, created_atand an index onstatusalone, each status is one group of hundreds of thousands of rows. To return the first rows of a group, it must read and sort the whole group. In the example below,LIMIT 2100read all 500,000 rows. Fix: an index on the full sort key,(status, created_at), so no sort is needed at all. - Bad estimates of group size. The planner chooses it based on the estimated number of distinct
values in the presorted column. If those statistics are stale, run
ANALYZE. - To compare plans,
SET enable_incremental_sort = offin your session shows the cost of a full sort instead.
Example
PostgreSQL 18.6, default settings:
CREATE SCHEMA seo_explain;
SET search_path = seo_explain;
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id int NOT NULL,
status text NOT NULL,
total numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO orders
SELECT i,
1 + (i::bigint * 7919) % 50000,
CASE WHEN i % 100 < 90 THEN 'shipped'
WHEN i % 100 < 97 THEN 'pending'
ELSE 'refunded' END,
round(((i * 37) % 100000) / 100.0, 2),
timestamptz '2024-01-01' + i * interval '1 minute'
FROM generate_series(1, 1000000) AS i;
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
CREATE INDEX orders_created_at_idx ON orders (created_at);
VACUUM ANALYZE orders;
(Our test table also had a foreign key to customers; it doesn’t change these plans.)
Orders by customer, oldest first within each customer, first 100:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders ORDER BY customer_id, created_at LIMIT 100;
Limit (cost=1.89..10.61 rows=100 width=34) (actual time=0.665..1.361 rows=100.00 loops=1)
Buffers: shared hit=83 read=27
-> Incremental Sort (cost=1.89..87206.24 rows=1000000 width=34) (actual time=0.665..1.355 rows=100.00 loops=1)
Sort Key: customer_id, created_at
Presorted Key: customer_id
Full-sort Groups: 3 Sort Method: quicksort Average Memory: 27kB Peak Memory: 27kB
Buffers: shared hit=83 read=27
-> Index Scan using orders_customer_id_idx on orders (cost=0.42..52136.34 rows=1000000 width=34) (actual time=0.036..1.322 rows=101.00 loops=1)
Index Searches: 1
Buffers: shared hit=77 read=27
Planning:
Buffers: shared hit=4
Planning Time: 0.026 ms
Execution Time: 1.369 ms
101 rows read out of a million, 1.4 ms. A full sort would have read and sorted all 1,000,000 rows to
return the first 100. PostgreSQL 14 produced the same plan (Full-sort Groups: 3, 28 kB).
When groups are large
A tickets table where only two status values exist, and an index on (status, id):
CREATE TABLE tickets (
id bigint PRIMARY KEY,
status text NOT NULL,
subject text NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO tickets
SELECT i, CASE WHEN i <= 2000 THEN 'open' ELSE 'closed' END, 'Ticket ' || i,
timestamptz '2025-01-01' + i * interval '2 minutes'
FROM generate_series(1, 500000) AS i;
CREATE INDEX tickets_status_id_idx ON tickets (status, id);
VACUUM ANALYZE tickets;
SET max_parallel_workers_per_gather = 0; -- keeps the plan short
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM tickets WHERE status IN ('open', 'closed')
ORDER BY status DESC, created_at DESC LIMIT 2100;
Limit (cost=26892.30..27031.96 rows=2100 width=35) (actual time=0.594..101.317 rows=2100.00 loops=1)
Buffers: shared hit=4969 read=1122
-> Incremental Sort (cost=26892.30..60008.27 rows=497925 width=35) (actual time=0.593..101.190 rows=2100.00 loops=1)
Sort Key: status DESC, created_at DESC
Presorted Key: status
Full-sort Groups: 2 Sort Method: quicksort Average Memory: 29kB Peak Memory: 29kB
Pre-sorted Groups: 2 Sort Methods: top-N heapsort, quicksort Average Memory: 189kB Peak Memory: 189kB
Buffers: shared hit=4969 read=1122
-> Index Scan Backward using tickets_status_id_idx on tickets (cost=0.42..23818.61 rows=497925 width=35) (actual time=0.040..50.358 rows=500000.00 loops=1)
Index Cond: (status = ANY ('{open,closed}'::text[]))
Index Searches: 1
Buffers: shared hit=4963 read=1122
Planning:
Buffers: shared hit=118 read=3
Planning Time: 0.296 ms
Execution Time: 101.397 ms
The 2,000 open tickets weren’t enough for LIMIT 2100, so it had to take 100 rows from the closed
group, and to know which 100 are newest it read and sorted all 498,000 of them. An index on
(status, created_at) would return the rows in the final order with no sort.
In Inlet
Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step and badly misestimated row
counts, so a scan that reads half a million rows under a LIMIT stands out.