PostgreSQL EXPLAIN
Append in PostgreSQL EXPLAIN
Append runs several child plans one after another and returns all their rows: one child per partition of a partitioned table, or per branch of a UNION ALL. The thing to check is how many children there are; partition pruning should have removed the ones that can’t match.
Updated 9 October 2026
What it does
Append has a list of child plans. It runs the first until it’s exhausted, then the second, and so on,
passing every row up. It doesn’t sort, deduplicate or combine rows; it concatenates.
You’ll see it for:
- Partitioned tables (and inheritance): one child per partition that might hold matching rows.
UNION ALL: one child per branch. (UNIONwithoutALLadds a step on top to remove duplicates.)
Parallel Append is the parallel version: instead of every process working through the children in
order, processes are spread across children, so several partitions are scanned at once.
When the output must be sorted and each child can provide sorted rows, Merge Append merges them instead of concatenating.
When the planner picks it
Always, for a query on a partitioned table or a UNION ALL, unless pruning leaves a single child (then
the Append is left out and you see the one scan directly).
Partition pruning decides how many children there are. It happens at two points:
- While planning, for conditions on the partition key with constant values
(
happened_at >= '2026-08-31 23:00'). Pruned partitions don’t appear in the plan at all. - When execution starts or while it runs, for values only known then:
now(), parameters of a prepared statement, values from a subquery or the outer side of a nested loop. Partitions pruned at startup are counted inSubplans Removed; those pruned later show as(never executed).
Reading its numbers
Append (cost=0.30..36.25 rows=122 width=30) (actual time=0.003..0.022 rows=120.00 loops=1)
Subplans Removed: 3
-> Index Scan using events_2026_10_happened_at_idx on events_2026_10 events_1 (…)
- The children: which partitions or branches were actually scanned. Count them first.
Subplans Removed: N: partitions pruned when execution started. Here 3 of 4 partitions were skipped becausenow()was only known then.(never executed)on a child: it was in the plan but never run, because it was pruned during execution or aLIMITabove was already satisfied.rows: the sum of the children’s rows.events_1,events_2…: aliases PostgreSQL gives each partition’s scan, numbered in partition order.- Under
Parallel Append, children show differentloopsdepending on how many processes worked on them: a child withloops=1was scanned by one process alone.
When it’s a problem
- Every partition is scanned. The query doesn’t let PostgreSQL prune:
- No condition on the partition key. Partitioning by
happened_atonly helps queries that filter onhappened_at. - The key is wrapped in a function or cast:
date_trunc('day', happened_at) = '2026-09-01'can’t prune;happened_at >= '2026-09-01' AND happened_at < '2026-09-02'can. - A volatile function in the comparison. Stable values such as
now()and prepared-statement parameters prune when execution starts; volatile ones such asrandom()don’t prune at all. enable_partition_pruningis off (it’s on by default).
- No condition on the partition key. Partitioning by
- Too many partitions. Partitions that can only be pruned at execution time still have to be
planned, and a query that touches hundreds of partitions opens and plans every one. Check
Planning Time; keep the partition count to what pruning needs (months rather than days for multi-year data, for example). - A slow child. The Append is only as fast as its children; look for the child with most of the time and treat it like any other scan.
Example
PostgreSQL 18.6, default settings. An events table partitioned by month, July to October 2026,
340,000 rows (one every 30 seconds):
CREATE SCHEMA seo_explain;
SET search_path = seo_explain;
CREATE TABLE events (
id bigint NOT NULL,
happened_at timestamptz NOT NULL,
kind text NOT NULL,
payload text
) PARTITION BY RANGE (happened_at);
CREATE TABLE events_2026_07 PARTITION OF events FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE events_2026_08 PARTITION OF events FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE events_2026_09 PARTITION OF events FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE events_2026_10 PARTITION OF events FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
INSERT INTO events
SELECT i, timestamptz '2026-07-01' + i * interval '30 seconds',
(ARRAY['login','view','click','purchase'])[1 + i % 4], 'p' || i
FROM generate_series(1, 340000) AS i;
CREATE INDEX events_happened_at_idx ON events (happened_at);
VACUUM ANALYZE events;
Two hours across a month boundary, pruned while planning: only two partitions appear.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE happened_at >= '2026-08-31 23:00' AND happened_at < '2026-09-01 01:00';
Append (cost=0.29..23.86 rows=251 width=30) (actual time=0.009..0.050 rows=240.00 loops=1)
Buffers: shared hit=7
-> Index Scan using events_2026_08_happened_at_idx on events_2026_08 events_1 (cost=0.29..10.65 rows=118 width=29) (actual time=0.008..0.018 rows=120.00 loops=1)
Index Cond: ((happened_at >= '2026-08-31 23:00:00+00'::timestamp with time zone) AND (happened_at < '2026-09-01 01:00:00+00'::timestamp with time zone))
Index Searches: 1
Buffers: shared hit=4
-> Index Scan using events_2026_09_happened_at_idx on events_2026_09 events_2 (cost=0.29..11.95 rows=133 width=30) (actual time=0.008..0.016 rows=120.00 loops=1)
Index Cond: ((happened_at >= '2026-08-31 23:00:00+00'::timestamp with time zone) AND (happened_at < '2026-09-01 01:00:00+00'::timestamp with time zone))
Index Searches: 1
Buffers: shared hit=3
Planning:
Buffers: shared hit=217 read=6 dirtied=2
Planning Time: 0.749 ms
Execution Time: 0.084 ms
The last hour, using now() (9 October 2026 when we ran it), pruned at execution start:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE happened_at >= now() - interval '1 hour' AND happened_at < now();
Append (cost=0.30..36.25 rows=122 width=30) (actual time=0.003..0.022 rows=120.00 loops=1)
Buffers: shared hit=5
Subplans Removed: 3
-> Index Scan using events_2026_10_happened_at_idx on events_2026_10 events_1 (cost=0.30..10.68 rows=119 width=30) (actual time=0.003..0.014 rows=120.00 loops=1)
Index Cond: ((happened_at >= (now() - '01:00:00'::interval)) AND (happened_at < now()))
Index Searches: 1
Buffers: shared hit=5
Planning:
Buffers: shared hit=40 read=2
Planning Time: 0.198 ms
Execution Time: 0.034 ms
A filter that isn’t on the partition key scans every partition, here as a Parallel Append:
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM events WHERE kind = 'purchase';
Finalize Aggregate (cost=6333.18..6333.19 rows=1 width=8) (actual time=15.898..17.215 rows=1.00 loops=1)
Buffers: shared hit=987 read=1582 written=11
-> Gather (cost=6332.96..6333.17 rows=2 width=8) (actual time=15.892..17.211 rows=3.00 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=987 read=1582 written=11
-> Partial Aggregate (cost=5332.96..5332.97 rows=1 width=8) (actual time=14.276..14.278 rows=1.00 loops=3)
Buffers: shared hit=987 read=1582 written=11
-> Parallel Append (cost=0.00..5244.98 rows=35195 width=0) (actual time=0.058..12.887 rows=28333.33 loops=3)
Buffers: shared hit=987 read=1582 written=11
-> Parallel Seq Scan on events_2026_08 events_2 (cost=0.00..1335.47 rows=13169 width=0) (actual time=0.005..4.625 rows=22320.00 loops=1)
Filter: (kind = 'purchase'::text)
Rows Removed by Filter: 66960
Buffers: shared hit=679
-> Parallel Seq Scan on events_2026_07 events_1 (cost=0.00..1313.46 rows=13070 width=0) (actual time=0.139..4.263 rows=7440.00 loops=3)
Filter: (kind = 'purchase'::text)
Rows Removed by Filter: 22320
Buffers: shared read=657 written=1
-> Parallel Seq Scan on events_2026_09 events_3 (cost=0.00..1295.29 rows=12567 width=0) (actual time=0.004..4.067 rows=10800.00 loops=2)
Filter: (kind = 'purchase'::text)
Rows Removed by Filter: 32400
Buffers: shared hit=308 read=352 written=2
-> Parallel Seq Scan on events_2026_10 events_4 (cost=0.00..1124.77 rows=10881 width=0) (actual time=0.096..7.956 rows=18760.00 loops=1)
Filter: (kind = 'purchase'::text)
Rows Removed by Filter: 56281
Buffers: shared read=573 written=8
Planning:
Buffers: shared hit=50 read=4 dirtied=1
Planning Time: 0.268 ms
Execution Time: 17.239 ms
The loops differ per partition: August and October were each scanned by one process, July by all
three. UNION ALL produces the same node; see Subquery Scan for an example.
In Inlet
Inlet draws EXPLAIN ANALYZE as a tree and highlights the slowest step and badly misestimated row
counts. It also lists a table’s partitions, so you can check what a query should have pruned.