PostgreSQL EXPLAIN
WindowAgg in PostgreSQL EXPLAIN
WindowAgg computes window functions such as row_number(), rank() and running sum() over rows sorted by PARTITION BY and ORDER BY. It needs a Sort or an index for that order, and since PostgreSQL 15 it can stop early on conditions like rn <= 3.
Updated 9 October 2026
What it does
A window function computes a value for each row from a set of related rows (its window) without
collapsing them the way GROUP BY does: a row number within each customer, a running total, the
previous row’s value. WindowAgg is the node that does it.
It reads rows sorted by the PARTITION BY columns and then the window’s ORDER BY, keeps the rows of
the current partition (or the current frame) in a buffer, and outputs each input row with the window
function results added. Several window functions over the same window share one WindowAgg; different
windows get one WindowAgg each, often with a Sort between them.
When the planner picks it
Whenever the query has a window function (… OVER (…)). The choices are about the input:
- A Sort on the partition and order columns, the usual case.
- An index that returns rows in that order, so no sort is needed.
- An Incremental Sort when an index covers the partition column but not the order within it.
Reading its numbers
PostgreSQL 18:
WindowAgg (cost=4860.42..4900.88 rows=2023 width=26) (actual time=69.193..69.471 rows=300.00 loops=1)
Window: w1 AS (PARTITION BY orders.customer_id ORDER BY orders.total ROWS UNBOUNDED PRECEDING)
Run Condition: (row_number() OVER w1 <= 3)
Storage: Memory Maximum Storage: 17kB
Window: w1 AS (…)(PostgreSQL 18): the window definition. Earlier versions don’t print it and write window functions asOVER (?).ROWS UNBOUNDED PRECEDINGhere is the frame PostgreSQL chose internally: since 16 it uses the fasterROWSmode for functions likerow_number()where the frame doesn’t change the result.Run Condition(PostgreSQL 15 and later): a condition from the outer query that, once false, stays false for the rest of the partition. Forrow_number() <= 3, after row 3 of a customer the WindowAgg stops returning that customer’s rows. It works forrow_number(),rank(),dense_rank()andcount(), and since 16 alsontile(),cume_dist()andpercent_rank().Storage: Memory Maximum Storage: 17kB(PostgreSQL 18): the buffer of rows held for the current partition or frame.Storage: Diskmeans it outgrewwork_memand used a temporary file.rows: rows output, which with a Run Condition can be far fewer than rows read.
When it’s a problem
- The Sort underneath. The sort usually costs more than the window functions. If it spills
(
external merge), raisework_memfor the query. An index on(partition columns, order columns)can remove it. - Top-N per group over a whole big table.
row_number() … WHERE rn <= 3still reads every row of every partition, even with a Run Condition, because it has to see each row to know where partitions start. If you want the top 3 orders for each of 50,000 customers and there’s an index on(customer_id, total DESC), aLATERALjoin withLIMIT 3reads three index entries per customer instead. In the example: 1,716 ms → 408 ms. - Large frames on disk. Running aggregates over big partitions with
RANGEframes, or functions likelast_value()over a whole partition, hold many rows.Storage: Diskon 18 shows it; raisework_memor narrow the frame. - Several different windows. Each distinct
OVER (…)needs its own order and often its own Sort. Reuse one window definition (WINDOW w AS (…)) where the queries allow.
Example
PostgreSQL 18.6, default settings:
CREATE SCHEMA seo_explain;
SET search_path = seo_explain;
CREATE TABLE customers (
id int PRIMARY KEY,
name text NOT NULL,
country text NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO customers
SELECT i, 'Customer ' || i,
(ARRAY['GB','US','DE','FR','NL','IE','ES','IT','SE','NO',
'DK','FI','PL','PT','BE','AT','CH','CA','AU','NZ'])[1 + (i * 7) % 20],
timestamptz '2023-01-01' + i * interval '20 minutes'
FROM generate_series(1, 50000) AS i;
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers (id),
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 customers, orders;
A running total per customer, for the first 100 customers:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, created_at, total,
sum(total) OVER (PARTITION BY customer_id ORDER BY created_at) AS running_total
FROM orders WHERE customer_id <= 100;
WindowAgg (cost=4860.42..4900.88 rows=2023 width=58) (actual time=2.801..3.699 rows=2000.00 loops=1)
Window: w1 AS (PARTITION BY customer_id ORDER BY created_at)
Storage: Memory Maximum Storage: 17kB
Buffers: shared hit=2007
-> Sort (cost=4860.42..4865.48 rows=2023 width=26) (actual time=2.752..2.828 rows=2000.00 loops=1)
Sort Key: customer_id, created_at
Sort Method: quicksort Memory: 142kB
Buffers: shared hit=2007
-> Bitmap Heap Scan on orders (cost=24.10..4749.33 rows=2023 width=26) (actual time=0.613..2.345 rows=2000.00 loops=1)
Recheck Cond: (customer_id <= 100)
Heap Blocks: exact=2000
Buffers: shared hit=2004
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..23.60 rows=2023 width=0) (actual time=0.417..0.418 rows=2000.00 loops=1)
Index Cond: (customer_id <= 100)
Index Searches: 1
Buffers: shared hit=4
Planning:
Buffers: shared hit=12
Planning Time: 0.116 ms
Execution Time: 3.903 ms
The same query on PostgreSQL 17.11 prints WindowAgg with no Window: or Storage: line beneath it.
Top three orders per customer, for every customer
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM (
SELECT id, customer_id, total,
row_number() OVER (PARTITION BY customer_id ORDER BY total DESC) AS rn
FROM orders
) ranked
WHERE rn <= 3;
WindowAgg (cost=2.02..104799.33 rows=1000000 width=26) (actual time=29.223..1219.612 rows=150000.00 loops=1)
Window: w1 AS (PARTITION BY orders.customer_id ORDER BY orders.total ROWS UNBOUNDED PRECEDING)
Run Condition: (row_number() OVER w1 <= 3)
Storage: Memory Maximum Storage: 17kB
Buffers: shared hit=991954 read=8998 written=5233
-> Incremental Sort (cost=1.91..87299.33 rows=1000000 width=18) (actual time=29.180..1123.454 rows=1000000.00 loops=1)
Sort Key: orders.customer_id, orders.total DESC
Presorted Key: orders.customer_id
Full-sort Groups: 25000 Sort Method: quicksort Average Memory: 26kB Peak Memory: 26kB
Buffers: shared hit=991954 read=8998 written=5233
-> Index Scan using orders_customer_id_idx on orders (cost=0.42..52135.36 rows=1000000 width=18) (actual time=27.774..932.547 rows=1000000.00 loops=1)
Index Searches: 1
Buffers: shared hit=991948 read=8998 written=5233
Planning:
Buffers: shared hit=140
Planning Time: 0.596 ms
JIT:
Functions: 7
Options: Inlining false, Optimization false, Expressions true, Deforming true
Timing: Generation 0.722 ms (Deform 0.080 ms), Inlining 0.000 ms, Optimization 2.185 ms, Emission 25.451 ms, Total 28.359 ms
Execution Time: 1715.854 ms
The Run Condition kept the output to 150,000 rows, but all 1,000,000 orders were read, one table
page at a time through the customer_id index, and sorted within each customer.
With an index on (customer_id, total DESC), a LATERAL join reads three entries per customer:
CREATE INDEX orders_customer_total_idx ON orders (customer_id, total DESC);
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id, top.id, top.total
FROM customers c
CROSS JOIN LATERAL (
SELECT o.id, o.total FROM orders o
WHERE o.customer_id = c.id
ORDER BY o.total DESC
LIMIT 3
) top;
Nested Loop (cost=0.42..656229.31 rows=150000 width=18) (actual time=66.699..402.899 rows=150000.00 loops=1)
Buffers: shared hit=296525 read=4225 written=3431
-> Seq Scan on customers c (cost=0.00..868.00 rows=50000 width=4) (actual time=0.356..12.226 rows=50000.00 loops=1)
Buffers: shared read=368 written=273
-> Limit (cost=0.42..13.08 rows=3 width=14) (actual time=0.004..0.006 rows=3.00 loops=50000)
Buffers: shared hit=296525 read=3857 written=3158
-> Index Scan using orders_customer_total_idx on orders o (cost=0.42..84.77 rows=20 width=14) (actual time=0.004..0.006 rows=3.00 loops=50000)
Index Cond: (customer_id = c.id)
Index Searches: 50000
Buffers: shared hit=296525 read=3857 written=3158
Planning:
Buffers: shared hit=37 read=3
Planning Time: 0.298 ms
JIT:
…
Execution Time: 408.405 ms
With the same index, the window-function query improved too (828 ms, no sort), but it still read
every order. The trade-off for either: one more index to maintain on writes to orders.
In Inlet
Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step and badly misestimated row
counts. With your own Anthropic API key, Ask Claude (⌘L) explains a plan; it sends the plan and the
schema, never rows.