LayoutENGINE-SPECIFICGENERALWAREHOUSE-SPECIFIC

Partition Pruning

The planner eliminating partitions before reading. It is a best-effort behaviour, not a guarantee — and there are four common predicate shapes that silently defeat it while looking completely correct.

What actually happensHow to build itCan I trust it?

Who needs this, what one row is, and why the obvious build breaks

Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.

The question

The table is partitioned by date, the query filters on date, and it still read the whole table. What stopped the planner from pruning?

Who needs this

The analyst who wrote a reasonable-looking filter and got a bill or a runtime they did not expect, and the platform engineer who has to explain why two queries that return the same answer cost wildly different amounts. Neither can see the plan unless someone makes it visible.

What one row is

The unit is the partition-elimination decision: for each partition in the table's list, the planner answers "could a row in here satisfy this predicate?" using only the partition value. Pruning is the set of partitions for which the answer is provably no.

The obvious build

Assume that filtering on the partition column prunes. It is the whole reason the table was partitioned, the query mentions the column, the planner is described as intelligent, and in the simple case it is completely true.

Why it breaks

The predicate is wrapped in a function: WHERE date_trunc('month', event_date) = '2026-08-01'. The planner cannot invert an arbitrary function, so it cannot decide which partition values satisfy it, so it keeps them all (The Planner: Enumerating Ways to Answer).

How it breaks with real data
  • The predicate is wrapped in a function: WHERE date_trunc('month', event_date) = '2026-08-01'. The planner cannot invert an arbitrary function, so it cannot decide which partition values satisfy it, so it keeps them all (The Planner: Enumerating Ways to Answer).
  • The types do not match: the partition value is a string in the path and the literal is a date, or vice versa. The engine inserts a cast — and a cast around the partition column is a function around the partition column, with the same consequence.
  • The predicate comes from a join: WHERE e.event_date = d.report_date against a small dimension. The value is not known at plan time, so unless the engine can push a dynamic filter down after the build side is computed, every partition is a candidate (Join Algorithms: Nested Loop, Hash, Merge).
  • The filter is on a different column that merely correlates with the partition: WHERE event_ts >= ... AND event_ts < ... on a table partitioned by event_date. The planner does not know the two are related (Partitioning).
  • The predicate is inside an OR with a non-partition condition, so no partition can be excluded — a row in any partition could satisfy the other branch.
  • The partition list itself is stale: partitions exist on storage but were never registered, so they are pruned in the most complete way possible and their data is simply absent from the answer (Metadata: Technical, Operational and Business).
SourceIngestionRawTransformationValidationStorage ModelServingConsumerObservability

What is actually happening

  • Pruning happens during planning, before execution, and it is a constant-folding problem. The planner must reduce the predicate to a condition it can evaluate against a partition value — a literal comparison, a range, an IN list — and then test each known partition value against it (Cost-Based Optimization).
  • Anything that stops the predicate from reducing to that form stops pruning. A function around the column, a cast around the column, a value that is not known until runtime, or a disjunction that reaches outside the partition column: each leaves the planner with no way to prove a partition is irrelevant, so it must include it.
  • Crucially, the query is still correct. Failing to prune never returns wrong rows; the filter is applied after reading instead of instead of reading. That is exactly why this is invisible: the only symptom is cost (Predicate Pushdown).
  • Where the partition list comes from decides how the test is performed. A catalog or manifest answers "what partitions exist" from metadata; a directory-based table answers it by listing storage, and listing is itself proportional to partition count (Open Table Formats).
  • Some engines support dynamic partition pruning: build the small side of a join first, collect the distinct join-key values, and use them to prune the large side before scanning it. Where it exists it fixes the join case entirely; where it does not, the join case is a full scan (Broadcast Joins).
  • A related mechanism operates one level down and is often confused with this one. File and row-group skipping uses column statistics inside files and happens during execution, not planning. It helps after pruning has already decided which files to open, and it cannot rescue a query that failed to prune (The Parquet Read Path).

Four years of directories, one read

SIMPLIFIEDThe file counts per partition are illustrative and uniform for legibility; a real table's partitions vary considerably, and that variation is itself a signal worth watching. The partition counts are structural — a day-partitioned table genuinely has one directory per day — rather than a measurement of anything.

Take a table with a partition per day and four years of history — call it well over a thousand directories. A query bounded to a single day is asked to consider all of them and reads one. That is the entire value proposition of partitioning, and it holds only when the planner can fold the predicate.

The layout below shows the same table under two predicates. The first prunes to one partition. The second is semantically identical for well-formed data, wrapped in a function, and reads every partition — including, notice, the currently-open one, which is a partial day. The second query is therefore not only expensive; on a table where the open partition is included, it can also produce a different number than the analyst intended.

The important habit is to stop thinking of a filter as a filter. On a partitioned table there are two kinds of predicate: those the planner can use to eliminate directories, and those it can only apply to rows it has already read. They look identical in the SQL and they are different operations.

The same table, two predicates
(a) WHERE event_date = DATE '2026-08-25' vs (b) WHERE date_trunc('day', event_date) = DATE '2026-08-25'
  • events/event_date=2023-01-01/ … 2026-08-22/one day of events per partition, more than a thousand partitions · 4 files · skipped
  • events/event_date=2026-08-23/one day of events · 4 files · skipped
  • events/event_date=2026-08-24/one day of events · 4 files · skipped
  • events/event_date=2026-08-25/one day of events · 3 files · read
  • events/event_date=2026-08-26/ (open)a partial day, still being written · 1 file · skipped
1 of 5 shown paths are read.

Both queries return identical rows. One considers a thousand path strings and opens three files; the other opens every file in the table. Nothing in the SQL, the result or the pipeline status distinguishes them — only the plan does.

Four shapes that silently defeat it

Each of the four shapes below is written by a competent engineer with a good reason. None of them produces an error, a warning or a wrong answer. All of them turn a partition-bounded query into a full scan, and the fix in every case is to express the same condition in a form the planner can reduce to a comparison against a partition value.

The type-mismatch case deserves special attention because it is the one most likely to survive review. The partition value lives in a path and is therefore a string at the storage layer; whether the engine presents it as a string or as a typed value is an engine decision, and comparing against the wrong type inserts a cast that wraps the column exactly as a function would.

The join case is the one that behaves differently across engines and is therefore the one most likely to regress on upgrade. A query that pruned dynamically last quarter and does not this quarter has changed by nothing you control, and the only symptom is cost.

How each shape presents in production
TriggerSymptomCauseResponse
A dashboard is changed to show "this month" using a truncation function.Scanned bytes for that dashboard step up sharply; the numbers are unchanged and correct.A function around the partition column removed pruning; the filter is now applied after reading.Rewrite as a half-open range on the bare column, and add a pruning-ratio check for that dashboard's query shape.
A parameter is passed from an application as a string.The same query is cheap from the SQL console and expensive from the application.An implicit cast wraps the partition column when the literal type does not match.Bind the parameter with the partition column's type; confirm from the plan that partition count read went back down.
A model joins a large fact table to a small calendar or control table to select dates.Fast in development against a short history, very slow in production against four years.The bound is not known at plan time and the engine did not apply dynamic pruning to this shape.Compute the range and inject it as a literal, or verify dynamic pruning in the plan rather than assuming it (Broadcast Joins).
An engine or warehouse version upgrade.Cost for a set of recurring queries changes with no code change on either side.Optimiser behaviour around pruning changed in one direction or the other.Capture plans for the top recurring queries on a schedule so the change is attributable to the upgrade rather than discovered later (Query Optimizers).
A new partition directory is written by a job that does not register it.Queries are fast, prune perfectly, and are missing a day.The catalog's partition list is the pruning input; a partition it does not know about is excluded with total confidence.Reconcile storage against the catalog on a schedule, and make registration part of the publish rather than a follow-up step (Atomic Publish).
The four shapes, and the rewrite for each
1-- 1. FUNCTION ON THE PARTITION COLUMN
2-- The planner cannot invert an arbitrary function, so it cannot decide
3-- which partition values could satisfy the condition.
4WHERE date_trunc('month', event_date) = DATE '2026-08-01' -- scans all
5WHERE event_date >= DATE '2026-08-01'
6 AND event_date < DATE '2026-09-01' -- prunes
7
8-- 2. TYPE MISMATCH -> AN IMPLICIT CAST, WHICH IS A FUNCTION
9-- A cast around the column has exactly the effect of a function
10-- around the column.
11WHERE event_date = '2026-08-25' -- may cast
12WHERE event_date = DATE '2026-08-25' -- prunes
13
14-- 3. A PREDICATE THE PLANNER CANNOT SEE AT PLAN TIME
15-- The bound comes from another table, so there is no literal to fold
16-- unless the engine implements dynamic pruning for this shape.
17SELECT sum(e.amount)
18FROM events e
19JOIN report_days d ON e.event_date = d.report_date; -- may scan all
20
21-- Resolve the bound first and inject it as a literal:
22SELECT sum(e.amount)
23FROM events e
24WHERE e.event_date BETWEEN DATE '2026-08-01' AND DATE '2026-08-25';
25
26-- 4. THE PARTITION COLUMN INSIDE AN OR WITH A NON-PARTITION COLUMN
27-- A row in any partition could satisfy the second branch, so no
28-- partition can be excluded.
29WHERE event_date = DATE '2026-08-25' OR customer_id = 'c-991' -- scans all
30
31-- If both branches are genuinely wanted, bound the partition on both:
32WHERE event_date BETWEEN DATE '2026-08-01' AND DATE '2026-08-25'
33 AND (event_date = DATE '2026-08-25' OR customer_id = 'c-991');

The rewrite is the same move every time: turn the condition into a bare-column comparison or range against a literal. Note that none of the left-hand versions is wrong — each returns exactly the right rows, which is precisely why nobody catches them.

The chain a prune depends on

Pruning is usually discussed as a property of the query. It is better understood as the last link in a chain that starts at write time, because every earlier link can break it — and the two most damaging failures are not query failures at all.

Walk the chain below from the top. A value is derived at write time; it becomes a path; the path becomes an entry in a partition list; the planner compares a predicate against that list; the surviving partitions become tasks. A defect at any node produces a query that is slow, or a query that is fast and incomplete, and only the last node produces the failure people expect.

The couldCorrupt column is what makes this worth drawing. Two of these nodes can cause missing data rather than slowness — a mis-derived partition value and a stale registry — and both of them make the query faster, which is the opposite of the symptom anyone is looking for.

From a value at write time to a pruned scan
  1. Partition value derived by the writer

    holds The result of deriving a partition key from a record — a date extracted from an event timestamp, in a specific time zone.

    could corrupt A time-zone or parsing difference routes rows into the neighbouring day, or into a catch-all null partition. Every subsequent step then works perfectly on data that is in the wrong place.

    ↑ reads from
  2. Path written to storage

    holds The encoded column=value segment that makes the value knowable without reading.

    could corrupt Inconsistent encoding — zero-padding, casing, a different column name — produces partitions some engines see and others do not.

    ↑ reads from
  3. Partition registered in the catalog or manifest

    holds The authoritative list of partitions the planner will consider.

    could corrupt A partition present on storage and absent here is excluded from every query with complete confidence. This is a missing-rows incident that presents as a fast query (Missing Rows).

    ↑ reads from
  4. Predicate as written by the consumer

    holds The condition the planner will try to fold into a comparison against partition values.

    could corrupt A function, a cast, a disjunction or a join-derived bound leaves nothing foldable, and every partition survives.

    ↑ reads from
  5. Planner's elimination decision

    holds The set of partitions that could contain a matching row.

    could corrupt Engine version differences, particularly for dynamic pruning, change this set without any change on either side of it.

    ↑ reads from
  6. Tasks scheduled over surviving files

    holds The actual work: one task per file, reading and filtering.

    could corrupt One partition far larger than the rest turns a well-pruned query into a single straggler task (Data Skew).

Two nodes cause slowness and two cause missing data. The ones that cause missing data — a mis-derived value and an unregistered partition — both make queries faster, which is why nobody investigates them.

Checks around pruning, and the blind spot in each
CheckExpressesCatchesStill misses
Partitions read versus partitions available, per recurring query shape.The layout is being exploited by the queries that actually run.A predicate regression from a dashboard change, a parameter type change, an optimiser behaviour change after an upgrade.A query that has always scanned everything — a stable ratio never regresses. Also any query shape not in the sample.
Partition directories on storage versus partitions in the catalog.Everything written is visible to readers.Unregistered partitions, which silently remove whole days from every answer.A partition that is registered and empty, and rows that were written into the wrong partition — both are perfectly consistent between storage and catalog.
Count of rows whose event date does not match their partition value.The writer routed each row to the partition its data says it belongs in.Time-zone drift, a parsing bug, late records forced into the current partition instead of their own.Rows where the event timestamp itself is wrong — the check confirms internal consistency, not truth (The Dimensions of Data Quality).
Size of the catch-all null partition.No records are failing to produce a usable partition key.Unparseable timestamps, missing fields, a schema change that nulled the source column.Records that produced a valid but wrong key, which land in a real partition and look entirely normal.
Bytes scanned per consumer, tracked over time.The cost of the platform is attributable to the things that cause it.Step changes from a pruning regression, and slow drift as history accumulates under a query that never pruned.Everything about correctness, and any regression small enough to hide inside normal variation (Scan Cost).

The first check is the one people build and the second is the one that catches an incident. A pruning ratio protects the bill; a storage-versus-catalog reconciliation protects the number.

How to build it

Most important first.

  • Always bound the partition column explicitly with a literal or a parameter, even when another predicate already implies the range. Redundant is fine; implicit is expensive (Partitioning).
  • Keep functions off the partition column. Rewrite date_trunc('month', d) = X as a range d >= start AND d < end, which is the same condition in a form the planner can fold.
  • Match types exactly. If the partition value is typed as a string by the engine, compare against a string; if it is a date, compare against a date literal. A silent cast is a silent full scan.
  • For join-derived date bounds, compute the range in the application or with a scalar subquery and inject it as a literal, rather than relying on the engine to discover it — unless you have verified that dynamic pruning is happening for that specific query shape.
  • Verify with the plan rather than with intuition, and then keep verifying. Every serious engine reports how many partitions or files it intends to read; that number is the only evidence pruning occurred, and turning it into a standing metric is what catches the regression a dashboard change introduces six months later (Reading EXPLAIN ANALYZE, Data Observability).
  • Keep the partition registry current. An unregistered partition is not a performance problem, it is a completeness problem, and it fails silently in the worst possible direction (Metadata: Technical, Operational and Business).

What this actually promises

Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.

  • Pruning is not guaranteed by anything. No SQL standard, no format and no engine promises that a filter on a partition column will eliminate partitions. It is an optimisation, and optimisations are permitted to not happen.
  • What is guaranteed is correctness in both directions: a query that prunes and a query that does not return the same rows. This is what makes the failure silent and what makes it safe to fix at any time.
  • The set of partitions considered is exactly the set the metadata layer knows about. That is a guarantee about the catalog, not about storage, and the difference between them is a source of missing data rather than of slow queries (The Data Catalog).
  • Dynamic pruning, where supported, guarantees nothing about timing: it can only prune the probe side after the build side has been computed, so a query that starts scanning first gets no benefit.

Can I trust it?

A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.

The check that would catch this
  • The check is on the read side: sample executed queries and record partitions-read versus partitions-available. Alert when the ratio for a known query shape changes, because that is a regression in someone's SQL rather than in the data (Pipeline Metrics).
  • A second, sharper check for the completeness failure: compare the set of partition directories present on storage with the set the catalog knows about, and alert on any difference. This catches the case where pruning is working perfectly and the answer is still missing days (Missing Rows).
  • What both miss is a query that reads exactly the right partitions and computes the wrong thing, and a query that was always a full scan and always will be — a stable bad ratio raises no alert, because there is nothing to regress from.
Freshness
  • Pruning has no effect on freshness and is worth mentioning only because the two are confused during incidents: a fast query over stale partitions and a slow query over fresh ones look like the same complaint from a consumer ("the dashboard is wrong today").
  • Pruning does change how much of the recent, still-open partition a query touches. A query bounded to yesterday avoids today's partially-written partition entirely, which is often the difference between a stable number and one that moves during the day (Atomic Publish).
  • A query that fails to prune reads the open partition along with everything else, and therefore includes a partial period in an aggregate that was supposed to exclude it. Failure to prune can produce a *different number*, indirectly, for exactly this reason.
When the schema or meaning changes
  • A schema change that alters the partition column's type changes whether existing predicates prune. Nothing about the data moves, no consumer sees an error, and every query that compared against the old type now carries an implicit cast (Breaking Schema Changes).
  • Adding a partition column changes the shape of every predicate that used to prune, because a partial predicate on a multi-column partition scheme prunes only the leading dimensions it constrains.
  • Engine upgrades change pruning behaviour, in both directions. A query that pruned last quarter may not this quarter, and the change will appear in cost rather than in any test (Query Optimizers).
How to re-run this safely
  • Nothing to recover. A query that failed to prune produced a correct answer expensively; rewriting the predicate is the whole fix and it takes effect on the next run.
  • The completeness failure — partitions on storage, absent from the catalog — does require recovery: repair the registry, then re-run every downstream computation that read the table while it was incomplete (Backfills).
  • When a stale partition list has been feeding a dashboard for a while, the honest recovery includes telling consumers which periods were understated, because the numbers they already acted on were wrong (Data Incidents).

What can go wrong

Failure modes
  • A predicate wrapped in a function that looks more precise than a range and prunes nothing.
  • A silent cast inserted by the engine because the literal's type does not match the partition column's.
  • A join-derived date filter that the engine cannot resolve at plan time, on a query that was fast in development against a small table.
  • A BI tool generating predicates the platform team never sees, in shapes that defeat pruning by construction (Stale Dashboards).
  • A partition registry that drifts from storage, so pruning is perfect and the answer is incomplete — the failure of the mechanism working exactly as designed on wrong inputs.
  • A pruning-ratio monitor that only alerts on regressions, and therefore never fires for the query that has been a full scan since the day it was written.
Misreads
  • "The query mentions the partition column, so it pruned." Mentioning is not the same as constraining in a foldable form. A function, a cast or a disjunction around that mention removes the entire benefit.
  • "Failing to prune returned wrong data." It never does. It returns the same rows more expensively, and any wrongness is a separate problem that happened to be noticed at the same time.
  • "Predicate pushdown and partition pruning are the same thing." Pruning eliminates whole partitions during planning using path values; pushdown pushes a filter into the reader so it can skip files and row groups using column statistics during execution. They are different levels of the same hierarchy and one cannot substitute for the other (Predicate Pushdown).
  • "We are on a modern engine, so it handles this." Engines vary considerably, especially on the join-derived case, and the same query can prune on one and scan on another (Query Engines).
  • "Pruning ratio is high, so the layout is right." A high ratio on the queries you sampled says nothing about the ones you did not, and a query that has always scanned everything has a stable ratio that no regression alert will ever fire on.

Operating it

How you see it in production
  • Partitions or files read per query, from the engine's own execution statistics, attributed to the model or dashboard that issued the query (Scan Cost).
  • Bytes scanned per query over time for the top consumers — the aggregate signal in which a pruning regression appears as a step change (What Actually Drives Data Platform Cost).
  • The plan for expensive recurring queries, captured on a schedule rather than inspected during incidents, so the pruning decision is visible before it costs anything (Reading EXPLAIN ANALYZE).
  • Count of partitions on storage versus in the catalog, as a scheduled reconciliation (Reconciliation).
What changes at 10x and 100x
  • At 10x history, the cost of a failed prune grows linearly with the number of partitions, which is why the same badly-shaped predicate is invisible in a new platform and dominant in a four-year-old one.
  • At 100x, the planning cost of evaluating a predicate against a very large partition list becomes visible on its own, and metadata layers that answer from a manifest rather than a listing become the difference between a plan that takes moments and one that takes minutes (Open Table Formats).
  • Consumer growth scales the number of distinct predicate shapes hitting the table, and therefore the number of ways pruning can quietly not happen. This is a governance problem before it is a technical one (The Metrics Layer).
What drives cost here
  • The entire cost story of this lesson is bytes scanned, and the difference between pruning and not pruning is the difference between one partition and all of them (Scan Cost).
  • Planning itself has a cost proportional to partition count, and it is paid whether or not pruning succeeds. On a heavily partitioned table, planning is a meaningful share of a short query's total time (Partition Cardinality).
  • A failed prune also multiplies request count, because every partition's files must be listed and opened. Both cost drivers move together in the wrong direction (Object Storage).
What this approach costs
  • Writing predicates that prune means writing slightly more verbose SQL — bounding the partition column explicitly even when it is implied. That is a small, permanent tax on every query author in exchange for a large, permanent saving.
  • Relying on dynamic pruning couples your cost profile to an engine optimisation that can change between versions. Injecting literal bounds is uglier and more stable.
  • Monitoring the pruning ratio requires collecting and attributing query statistics, which is an observability investment that pays only on a platform large enough for a bad predicate to matter.

Where this applies

Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.

  • ENGINE-SPECIFICSupport for dynamic partition pruning on join-derived predicates differs sharply between engines and between versions of the same engine, so the join case is a full scan in some environments and fully pruned in others. Verify with a plan rather than assuming, and re-verify after an engine upgrade.
  • GENERALThat a function or cast around a filtered column defeats the optimiser is the same rule as in relational databases, where wrapping an indexed column in a function makes the index unusable. The mechanism differs — partition list versus index — and the rewrite is identical: express the condition as a range on the bare column.
  • WAREHOUSE-SPECIFICWarehouses that manage physical layout internally prune on their own metadata rather than on directory paths, so the predicate-shape rules still apply but the unit eliminated is a micro-partition you cannot see or address. The diagnostic is the scanned-bytes figure in the query profile rather than a partition count.

Where the depth lives

This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.

Domains that do not exist yet
  • DevOps / Production Engineering owns treating an engine or warehouse version upgrade as a change that needs a before-and-after comparison. Optimiser behaviour is part of your production surface even though it is nobody's code.