skip to content

A query filters on the very column a table is partitioned by, yet the plan still reads every partition. What common mistakes in how the predicate is written prevent pruning, and how would you rewrite them?

level: middleimportance: must knowfreq 56%

answer

  1. never wrap the key: no date_trunc, cast, to_char, AT TIME ZONE
  2. rewrite as half-open range on the raw column
  3. implicit cast on the column side kills pruning
  4. hash prunes on equality only
  5. join- or subquery-supplied values have no plan-time constant

basics

~20 s

Wrapping the partition key in a function or cast, comparing it to a mismatched type, filtering on a derived column instead of the key, using an operator the partitioning strategy cannot use (a range test on hash partitions), or having no direct predicate at all because the restriction is on a joined table. Rewrite as a plain half-open range on the raw key.

solid answer

~60 s

Pruning needs the planner to compare the raw partition key against constants or parameters. It is defeated when: - **the key is wrapped**: `date_trunc('day', occurred_at) = ...`, `cast(occurred_at as date) = ...`, `to_char(...)`. The bound is on the column, not on the expression, so nothing can be proved. Rewrite as `occurred_at >= '2026-03-01' AND occurred_at < '2026-03-02'`. - **types mismatch**: comparing a timestamptz key to a string or date literal that forces an implicit cast on the column side, or a bigint key to a numeric value. - **the predicate is on the wrong column**: a related timestamp that happens to correlate with the key proves nothing. - **the operator does not suit the strategy**: hash partitioning prunes only on equality; a range comparison excludes nothing. - **the restriction lives elsewhere**: the key value comes from a join or a subquery, so there is no constant at plan time. Sometimes execution-time pruning saves it; often the fix is to add the literal range to the query. - **time-zone conversions** applied to the column, which is the wrapping case in disguise. Verify by reading the plan, not by assuming.

code

sql · 8 lines
sql
-- no pruning: the key is wrapped in an expression
SELECT count(*) FROM events
WHERE date_trunc('day', occurred_at) = DATE '2026-03-14';

-- prunes: plain half-open range on the raw partition key
SELECT count(*) FROM events
WHERE occurred_at >= TIMESTAMPTZ '2026-03-14 00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2026-03-15 00:00+00';

go deeper

for a junior

Know the headline rule: compare the partition column itself to values, never wrap it in a function or cast, and prefer a plain range filter.

for a middle

Enumerate the blockers with rewrites: expressions and casts, type mismatches, wrong column, operator versus strategy, OR across columns, and verify in the plan.

for a senior

Add the no-constant-at-plan-time cases, when execution-time pruning rescues them and when it does not, plus stable versus volatile functions and composite keys.

for a principal

Treat it as an API contract between schema and workload: choose the partition key so the dominant queries prune naturally, and add guardrails such as plan checks in review or tests that assert the partition count in a plan.

## Why this is the most common partitioning bug Pruning is a proof, and proofs are fragile. The optimizer must show that no row in a partition can satisfy the predicate, and it can only do that when the predicate is a comparison between the **raw partition key** and something it can evaluate. Any transformation of the key breaks the link between the predicate and the declared bounds, and the plan quietly falls back to reading everything. It is quiet because the results are still correct, so the bug shows up only as latency. ## Wrapping the key in a function or cast The classic: ``` WHERE date_trunc('day', occurred_at) = DATE '2026-03-14' ``` The bounds are declared on `occurred_at`, not on `date_trunc('day', occurred_at)`, so the planner has no theorem connecting them, and every partition stays in the plan. The same applies to `cast(occurred_at AS date)`, `to_char(occurred_at, 'YYYY-MM')`, `occurred_at AT TIME ZONE 'UTC'`, `upper(region_code)`, and arithmetic like `id / 1000`. The rewrite is always the same shape: express the filter as a half-open range on the untouched column. ``` WHERE occurred_at >= TIMESTAMPTZ '2026-03-14 00:00+00' AND occurred_at < TIMESTAMPTZ '2026-03-15 00:00+00' ``` Half-open ranges also avoid boundary bugs with fractional seconds, and they match how range bounds are declared (inclusive lower, exclusive upper). ## Type mismatches and implicit casts If the literal's type does not match the key's, the engine may cast one side. Casting the literal is harmless; casting the **column** is fatal, because that is again a function on the key. This bites when a timestamptz key is compared against a string from an ORM, or when a numeric literal meets a bigint key. It also matters for correctness: comparing a timestamptz to a bare date literal resolves in the session time zone, so a query run from a differently configured client can silently address a different day. Bind the parameter with the right type. ## Filtering on a correlated but different column `created_at` and `occurred_at` may be within milliseconds of each other, and a human knows the query means the same thing, but the optimizer will not infer it. Only a predicate on the actual partition key prunes. If the application naturally filters on the other column, either partition by that column instead or have the query supply both. ## Operator versus partitioning strategy - **Range** partitioning prunes for equality, ranges, BETWEEN, IN, and prefix-anchored patterns that the planner can convert to a range. - **List** partitioning prunes for equality and IN, and for NOT IN by exclusion. - **Hash** partitioning prunes **only** for equality (and IN) on the key, because hashing destroys order. `WHERE customer_id > 5000` on a hash-partitioned table reads every partition, by construction rather than by mistake. With a composite partition key, predicates must generally constrain the leading column for pruning to be effective, the same way a composite index needs its leading column. ## No constant to compare against Sometimes there is nothing wrong with the predicate, there simply is no value at planning time: - the key value comes from a join to another table; - it comes from a scalar subquery; - it arrives as a bind parameter in a prepared statement that has settled on a reusable generic plan. Modern PostgreSQL handles several of these at execution time rather than plan time, so the plan may list every partition while the executor skips most of them. But some shapes get no help at all, notably a join whose driving side is scanned by a hash join. Practical fixes: pass the literal range in the query in addition to the join, restrict the query to a materialised value computed in the application, or, for co-partitioned tables, enable partition-wise joins. ## Other pruning killers worth naming - **OR across unrelated columns**: `WHERE occurred_at >= x OR status = 'urgent'` cannot prune, because rows matching the second branch could live anywhere. An OR of two ranges on the key does prune. - **Volatile functions** in the predicate: the planner cannot fold them to a constant. Note that `now()` and `current_timestamp` are stable within a statement and do allow pruning in PostgreSQL, whereas `random()` or a volatile user-defined function do not. - **Views and functions that hide the filter**, where the effective predicate is applied above the partitioned scan rather than pushed into it. ## Verify, do not assume Every item above is checkable in seconds by reading the plan: count the partition children under the Append node and compare against the partitions you expect. Make that check part of reviewing any query against a large partitioned table.

  • The application genuinely needs to filter by calendar day in a local time zone, while the table is partitioned by a UTC timestamp. How do you keep pruning?
    Convert on the literal side, not on the column. Compute the two UTC instants that bound the local day in the application or in a scalar expression, then compare the raw column against them with a half-open range. Applying AT TIME ZONE to the column is a function on the partition key and defeats pruning, and it also forces a per-row computation across the whole table.
  • A query joins orders to a small date-dimension table and filters on that table, and no pruning happens. What are your options?
    The planner has no constant for the partition key, because the restriction is one join away. The most reliable fix is to also supply the range literally on the partitioned table's key, so plan-time pruning applies. Otherwise you can hope for execution-time pruning when the join is a nested loop parameterised by the key, or enable partition-wise joins if both tables are partitioned identically on the join key.

saying these in an interview costs you the question

  • Believing that any mention of the partition key in WHERE is enough, regardless of functions or casts around it.
  • Adding an index on an expression of the partition key and expecting that to restore pruning; an expression index helps the scan, not the pruning proof.
  • Expecting range predicates to prune hash partitions.
  • Assuming the planner will infer a predicate on the partition key from a correlated column or from a join.

context