Partition Pruning
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.
$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.
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.
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.
-- 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.
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.
- 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
timestampvalue against thetimestamptzkey, 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 Removedis the line that says so. - Enabling
enable_partitionwise_joinglobally because it reads like a free optimization, then paying planning memory andwork_memmultiplication on every OLTP query. - Adding an
ORon 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.
- 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
EXPLAINfor literals, and readSubplans RemovedinEXPLAIN (ANALYZE)for the parameterized form the application actually sends. - Check the
loopscounts and the(never executed)markers when the key comes from a nested loop, since no summary line covers that case. - Enable
enable_partitionwise_joinandenable_partitionwise_aggregateper session or per role for the analytic workload, never inpostgresql.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.
$PARTITION functionMySQL partition pruning, visible in EXPLAIN PARTITIONSCitus pushes the same idea down to shards on worker nodesKnowledge 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