PostgreSQL EXPLAIN
Subquery Scan in PostgreSQL EXPLAIN
A Subquery Scan reads the rows of a subquery that PostgreSQL couldn’t merge into the outer query, and applies the outer query’s conditions or column list to them. A Filter on it means those conditions only ran after the subquery had produced everything.
Updated 9 October 2026
What it does
PostgreSQL usually flattens a subquery in FROM (or a view) into the outer query, so the two are
planned as one and you never see the boundary. Some subqueries can’t be flattened without changing
their meaning: ones with DISTINCT or DISTINCT ON, LIMIT, GROUP BY and aggregates, window
functions, set operations. For those, the subquery is planned on its own, and a Subquery Scan node
reads its output for the outer query.
Its jobs are to apply a Filter (outer conditions that couldn’t be pushed inside) and to produce the
columns the outer query wants. When neither is needed, the planner leaves it out, which is why you
don’t see one for every subquery.
UNION ALL branches also show up as Subquery Scan on "*SELECT* 2" and so on, when a branch needs
its columns adjusted, for example to add a constant.
When the planner picks it
- The outer query filters on a column computed inside a subquery that can’t be flattened:
WHERE rn = 2on a window function’s result,WHERE total > 500on rows picked byDISTINCT ON. - A
UNION ALLbranch selects expressions or constants that the other branches compute differently. - Some views, when queried with conditions that can’t be pushed through their
DISTINCT,LIMITor window functions.
PostgreSQL does push conditions into a subquery when it’s safe: a condition on a GROUP BY column, for
instance, can be applied before grouping. A Filter on the Subquery Scan is what’s left when it
isn’t safe.
Reading its numbers
Subquery Scan on latest (cost=9693.16..9859.59 rows=3081 width=26) (actual time=11.219..12.073 rows=259.00 loops=1)
Filter: (latest.total > '500'::numeric)
Rows Removed by Filter: 241
-> Unique (… rows=500.00 loops=1)
on latest: the subquery’s alias."*SELECT* 2"names the second branch of aUNION.Filter: outer conditions applied to the subquery’s rows.Rows Removed by Filter: rows the subquery produced only for them to be thrown away. Compare with the child’srows: here 500 produced, 259 kept.- Everything else (time, buffers) is mostly the subquery’s.
When it’s a problem
- The subquery does far more work than the result needs. A large
Rows Removed by Filtermeans the inner query computed rows that were discarded. Ask whether the condition can be moved inside. It often can’t without changing the meaning: “the latest order per customer, if it’s over 500” is not the same as “the latest order over 500 per customer”. When it can, write it inside. - Top-N per group with a window function.
WHERE rn <= 3onrow_number()used to be a Subquery Scan with a filter over every row of every partition. Since PostgreSQL 15, the WindowAgg stops computing a partition once the condition can no longer be true (Run Condition), and forrn <= 3the Subquery Scan disappears. See the version comparison below and WindowAgg. - A view that blocks push-down. Querying a view with
DISTINCT,LIMITor window functions with a selectiveWHEREmakes the view compute everything first. A function or a version of the view that takes the condition inside avoids that.
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;
Each of the first 500 customers’ latest order, kept only if it’s over 500:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM (
SELECT DISTINCT ON (customer_id) customer_id, id, total, created_at
FROM orders WHERE customer_id <= 500
ORDER BY customer_id, created_at DESC
) latest
WHERE total > 500;
Subquery Scan on latest (cost=9693.16..9859.59 rows=3081 width=26) (actual time=11.219..12.073 rows=259.00 loops=1)
Filter: (latest.total > '500'::numeric)
Rows Removed by Filter: 241
Buffers: shared hit=8346
-> Unique (cost=9693.16..9744.05 rows=9243 width=26) (actual time=11.211..12.028 rows=500.00 loops=1)
Buffers: shared hit=8346
-> Sort (cost=9693.16..9718.61 rows=10178 width=26) (actual time=11.209..11.599 rows=10000.00 loops=1)
Sort Key: orders.customer_id, orders.created_at DESC
Sort Method: quicksort Memory: 853kB
Buffers: shared hit=8346
-> Bitmap Heap Scan on orders (cost=119.30..9015.65 rows=10178 width=26) (actual time=1.454..8.010 rows=10000.00 loops=1)
Recheck Cond: (customer_id <= 500)
Heap Blocks: exact=8334
Buffers: shared hit=8346
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..116.76 rows=10178 width=0) (actual time=0.666..0.666 rows=10000.00 loops=1)
Index Cond: (customer_id <= 500)
Index Searches: 1
Buffers: shared hit=12
Planning Time: 0.090 ms
Execution Time: 12.143 ms
total > 500 couldn’t move inside the DISTINCT ON without changing which order counts as the latest,
so all 500 latest orders were found first and 241 dropped afterwards.
A UNION ALL branch that adds a constant:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, 'order' AS kind, created_at FROM orders WHERE customer_id = 4242
UNION ALL
SELECT id, 'customer', created_at FROM customers WHERE id = 4242;
Append (cost=4.58..90.32 rows=21 width=48) (actual time=0.203..0.593 rows=21.00 loops=1)
Buffers: shared read=26
-> Bitmap Heap Scan on orders (cost=4.58..81.89 rows=20 width=48) (actual time=0.202..0.472 rows=20.00 loops=1)
Recheck Cond: (customer_id = 4242)
Heap Blocks: exact=20
Buffers: shared read=23
-> Bitmap Index Scan on orders_customer_id_idx (cost=0.00..4.58 rows=20 width=0) (actual time=0.073..0.073 rows=20.00 loops=1)
Index Cond: (customer_id = 4242)
Index Searches: 1
Buffers: shared read=3
-> Subquery Scan on "*SELECT* 2" (cost=0.29..8.32 rows=1 width=48) (actual time=0.117..0.118 rows=1.00 loops=1)
Buffers: shared read=3
-> Index Scan using customers_pkey on customers (cost=0.29..8.31 rows=1 width=44) (actual time=0.112..0.112 rows=1.00 loops=1)
Index Cond: (id = 4242)
Index Searches: 1
Buffers: shared read=3
Planning:
Buffers: shared hit=82 read=5
Planning Time: 0.364 ms
Execution Time: 0.647 ms
Here the Subquery Scan only converts the second branch’s columns to match the first. It costs nothing worth worrying about.
Top three orders per customer, PostgreSQL 14 against 15 and later
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 WHERE customer_id <= 100
) ranked
WHERE rn <= 3;
PostgreSQL 14.24 computes every row number, then filters:
Subquery Scan on ranked (cost=4753.17..4816.77 rows=652 width=26) (actual time=217.059..218.929 rows=300 loops=1)
Filter: (ranked.rn <= 3)
Rows Removed by Filter: 1700
Buffers: shared hit=9 read=2001
-> WindowAgg (cost=4753.17..4792.31 rows=1957 width=26) (actual time=217.058..218.845 rows=2000 loops=1)
…
PostgreSQL 15.19 (and 16–18) has no Subquery Scan at all; the condition became a Run Condition on the
WindowAgg, which stops numbering a partition after row 3:
WindowAgg (cost=4740.96..4779.96 rows=1950 width=26) (actual time=195.439..195.796 rows=300 loops=1)
Run Condition: (row_number() OVER (?) <= 3)
…
With WHERE rn = 2 instead, 17 and 18 keep the Subquery Scan (equality isn’t a stopping condition by
itself) but still give the WindowAgg a Run Condition of row_number() <= 2, so the filter removes
100 rows instead of 14’s 1,900.
In Inlet
Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step and badly misestimated row
counts.