InletDownload

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. (UNION without ALL adds 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 in Subplans 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 because now() was only known then.
  • (never executed) on a child: it was in the plan but never run, because it was pruned during execution or a LIMIT above 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 different loops depending on how many processes worked on them: a child with loops=1 was scanned by one process alone.

When it’s a problem

  1. Every partition is scanned. The query doesn’t let PostgreSQL prune:
    • No condition on the partition key. Partitioning by happened_at only helps queries that filter on happened_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 as random() don’t prune at all.
    • enable_partition_pruning is off (it’s on by default).
  2. 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).
  3. 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.

Related

Sources