Topic 58

Partition Pruning

Query Planning

Pruning is the whole performance case for partitioning. A query that says something about occurred_at touches only the partitions whose bounds could hold a matching row; the other eleven are never opened, never read and never counted. Nothing else about splitting a table makes a query faster, and a partitioned table whose queries do not prune is strictly worse than the single table it replaced.

It is also the part people assume rather than check. Pruning is driven only by the partition bounds, not by any index, and it happens in two quite different places: in the planner when the value is a constant, and in the executor when it is not. Each leaves a different mark in EXPLAIN, and one of the two marks is a single line you have to know to look for.

Two places pruning happens, two different marks in EXPLAIN
Plan time the value is a constant
The planner compares the predicate against each partition's bounds and discards the ones that cannot contribute. Count the scan nodes under the Append and compare that with the number of months the predicate covers.
Execution time the value is a parameter
A prepared statement's $1, a subquery, or the outer side of a nested loop. Pruned at initialization, the only trace is Subplans Removed: 11; pruned per iteration, it is (never executed) on each scan node.

Plan-Time Pruning

With a literal, or an expression the planner can fold to a constant before execution, the planner compares the predicate against each partition's bounds and discards the ones that cannot contribute. Those partitions are gone from the plan, not marked and not skipped at run time but simply absent, so the plan itself is the proof. The setting that governs this is enable_partition_pruning, on by default; the only reason to name it is that turning it off is how you produce the unpruned plan to compare against.

A two-month window over twelve monthly partitions
EXPLAIN SELECT event_type, count(*) FROM delivery_events
 WHERE occurred_at >= '2026-07-01' AND occurred_at < '2026-09-01'
 GROUP BY event_type;

 HashAggregate
   ->  Append
         ->  Seq Scan on delivery_events_2026_07 delivery_events_1
         ->  Seq Scan on delivery_events_2026_08 delivery_events_2

Two scan nodes under the Append, for a table with twelve partitions. That count is the diagnostic, and it is the one to build into a review habit: read the number of scan nodes and compare it to the number of months the predicate covers. The predicate here is a half-open range written against the raw column, greater-or-equal on the lower bound and strictly-less on the upper, which is exactly how the partition bounds themselves are declared.

Execution-Time Pruning

Application queries rarely carry literals. They arrive as prepared statements with $1, or the partition key comes from a subquery, or it comes from the outer side of a nested loop. The planner cannot compare an unknown value against the bounds, so since 11 Postgres prunes during execution instead, at two distinct moments. The first is plan initialization, when parameters that are already known get compared against the bounds; partitions removed at that point do not appear in EXPLAIN or EXPLAIN ANALYZE at all, and the only trace they leave is a count.

The line that proves a parameterized query pruned
PREPARE ev(timestamptz, timestamptz) AS
  SELECT count(*) FROM delivery_events
   WHERE occurred_at >= $1 AND occurred_at < $2;

EXPLAIN (ANALYZE) EXECUTE ev('2026-08-01', '2026-09-01');

 Aggregate (actual time=812.4..812.4 rows=1 loops=1)
   ->  Append (actual time=0.031..655.2 rows=118942317 loops=1)
         Subplans Removed: 11                -- ← the proof
         ->  Seq Scan on delivery_events_2026_08 …

The second moment is during execution proper, when the value changes as the query runs — a parameterized nested loop re-prunes on every new outer row. There is no summary line for that case. You read the loops count on each partition's scan node instead, and partitions that were pruned on every iteration show up as (never executed). One caveat worth carrying: partitions removed during initialization are still locked at the start of execution, so pruning saves I/O and CPU rather than lock acquisition, which is another reason a partition count in the thousands is not free even when queries prune well.

What Defeats Pruning

There are four predicates that turn a pruned query back into a scan of everything, and the first is by far the most common. Wrapping the key in a function hides it: date_trunc('month', occurred_at) is not occurred_at, and the planner has no way to invert it into a range of bounds. The fix is to state the range the function was standing in for. The second is a type that has to be converted before it can be compared — a timestamp value against a timestamptz key, where the conversion depends on the session's TimeZone and therefore is not something the planner can fold into a constant. Chapter 2 argued for timestamptz on correctness grounds; this is the operational bill for having ignored it.

Same rows, same answer, eleven partitions of difference
-- scans every partition: the key is inside a function call
SELECT count(*) FROM delivery_events
 WHERE date_trunc('month', occurred_at) = '2026-08-01';

-- prunes to one: the same window stated against the raw column
SELECT count(*) FROM delivery_events
 WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';

The third is an OR that reaches outside the key: WHERE occurred_at >= '2026-08-01' OR event_type = 'failed' cannot exclude any partition, because a failed event in 2024 satisfies the query. The fourth is the plain case of a query that never mentions the partition key at all: a report grouped by event_type over all history, which was always going to read the whole table and now reads it through twelve scan nodes instead of one. All four live in the query text, and all four are fixable there.

What the predicate does to twelve partitions
date_trunc('month', occurred_at) = '2026-08-01' — the key is inside a functionAll twelve scanned
A timestamp value compared against the timestamptz keyAll twelve scanned
occurred_at >= '2026-08-01' OR event_type = 'failed'All twelve scanned
A report grouped by event_type that never mentions the keyAll twelve scanned
occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01'One scanned

Ordering and Merge Append

Range partitions are stored in key order, and the planner knows it. A query for the newest events sorted by occurred_at can therefore be answered by reading the partitions in reverse bound order, taking an ordered index scan from each, and emitting rows that are already in the requested order — no sort node, and no need to have read anything from the older partitions. With a LIMIT 20 the executor stops as soon as the newest partition has produced twenty rows, and every other partition's scan node reports (never executed).

A Merge Append appears when the requested ordering does not line up with the partition ordering, sorting by order_id for instance, where matching rows can come from any month and the node has to interleave streams from all of them. The distinction matters because the two look similar in a plan and behave very differently: one opens a single partition and stops, the other opens all twelve. This section is also the honest exception to this chapter's framing. A "latest events" query genuinely gets faster from partitioning, rather than merely becoming easier to maintain.

Partitionwise Join and Aggregate

When two tables are partitioned the same way on the same key, a join between them can be done partition by partition instead of over the whole set, and an aggregate can be computed per partition and combined. Postgres will do both, and does neither by default: enable_partitionwise_join and enable_partitionwise_aggregate are off. The reason is in the documentation and it is a real one. Turning them on multiplies the number of plan nodes whose memory is bounded by work_mem, linearly with the partition count, so a query that was allowed 4 MB per hash node can now ask for that much per partition per node, and planning itself gets significantly more expensive in both CPU and memory.

Partitionwise join also has conditions: the join conditions must include all the partition keys, the keys must be of the same type, and the two tables must have one-to-one matching sets of partitions. Version 18 widened the cases where it applies and cut its memory use, which makes it more attractive than it was without changing the shape of the decision. Turn it on for the session or the role that runs the analytics workload, measure the plan and the memory, and leave the checkout path alone: Cartwheel's dashboard on pg-replica-a runs a handful of long queries, and the API runs 200,000 short ones an hour.

Planning Cost Grows With Partitions

The planner has to consider each partition in order to prune it, so partition count is never entirely free. The documentation's position is specific: hierarchies of up to a few thousand partitions are handled well provided typical queries prune all but a small number, and both planning time and memory rise with how many partitions survive pruning. Version 18 improved the efficiency of planning against many partitions, which moves the ceiling rather than removing it. There is a second cost that appears on no plan: each session that touches a partition loads that partition's metadata into its own local memory, so a hundred backends touching a thousand partitions each carry a hundred thousand copies of it between them.

This is the concrete reason Cartwheel's partitions are monthly. Twelve relations cost nothing measurable on a query that prunes to one, while the same twelve-month window at daily granularity is 365 relations: still inside "a few thousand", still working, and now paying planning time on every one of the API's short queries to support a retention rule that is expressed in months anyway. The granularity is worth re-deriving whenever the retention window changes.

Common Mistakes
  • Filtering with date_trunc('month', occurred_at) = … — every partition is scanned, and the incident is written up as partitioning not working rather than as a predicate that hid the key.
  • Comparing a timestamp value against the timestamptz key, so the conversion depends on the session's time zone and the planner cannot reduce it to a bound comparison.
  • Concluding that a parameterized query cannot prune because the plan looks unpruned — it prunes during execution, and Subplans Removed is the line that says so.
  • Enabling enable_partitionwise_join globally because it reads like a free optimization, then paying planning memory and work_mem multiplication on every OLTP query.
  • Adding an OR on a non-key column to an otherwise well-pruned query, which removes every partition's exclusion at once and changes nothing in the result.
  • Creating daily partitions for a twelve-month retention window and then reporting the resulting planning time as a limitation of Postgres.
Best Practices
  • Write every time filter as a half-open range against the raw partition key, and treat a function call around that column as a defect.
  • Verify pruning before shipping: count the scan nodes in EXPLAIN for literals, and read Subplans Removed in EXPLAIN (ANALYZE) for the parameterized form the application actually sends.
  • Check the loops counts and the (never executed) markers when the key comes from a nested loop, since no summary line covers that case.
  • Enable enable_partitionwise_join and enable_partitionwise_aggregate per session or per role for the analytic workload, never in postgresql.conf.
  • Keep the partition count in the low hundreds so that planning cost stays invisible on the shortest queries.
  • Add the pruning check to the same review that approves a new query against a partitioned table, alongside the plan reading from Chapter 9.
Comparable toolsOracle static and dynamic partition pruning, same failure modesSQL Server partition elimination via the $PARTITION functionMySQL partition pruning, visible in EXPLAIN PARTITIONSCitus pushes the same idea down to shards on worker nodes

Knowledge Check

EXPLAIN (ANALYZE) on a prepared statement shows one scan node and the line "Subplans Removed: 11". What does that mean?

  • Eleven partitions were scanned and returned no matching rows at all
  • Eleven partitions were pruned at initialization using the bound parameters
  • Eleven partitions were dropped from the plan because it grew too large
  • Eleven partitions were skipped because they lack the required index

Why does WHERE date_trunc('month', occurred_at) = '2026-08-01' scan every partition?

  • The function is volatile, so its result could differ on each partition
  • The predicate is about a derived value, not about the partition key itself
  • Equality predicates never prune on a range-partitioned table, only ranges do
  • There is no expression index on date_trunc('month', occurred_at) to use

Why are enable_partitionwise_join and enable_partitionwise_aggregate off by default?

  • They are still experimental and can return wrong results on some joins
  • They multiply work_mem-bounded nodes with the partition count and cost planning
  • They only apply to the legacy inheritance-based partitioning scheme
  • They disable partition pruning, so every partition would then be scanned

ORDER BY occurred_at DESC LIMIT 20 on a range-partitioned table. What does the plan let the executor do?

  • Read every partition, then sort the combined result and take the first twenty
  • Prune to the newest partition at plan time, since ORDER BY implies a bound
  • Read partitions in reverse bound order and stop once twenty rows are produced
  • Interleave a stream from all twelve partitions through a Merge Append node

What is the concrete cost of a partition count in the thousands, even when queries prune well?

  • Planning work per query, locks still taken, and per-session partition metadata
  • Pruning stops applying above a documented threshold of partition count
  • Storage grows, since the same rows are held across many more data files
  • Index cascading from the parent stops working past a few hundred children

You got correct