skip to content

In a query plan, what does it mean to "push a predicate down", and what does the optimizer gain by doing it? Include projection pushdown in your answer.

level: juniorimportance: must knowfreq 70%

answer

  1. filter early, carry fewer rows
  2. column pruning = read only needed columns
  3. fence: outer-join null side, HAVING, LIMIT
  4. pushdown enables partition elimination
  5. SELECT * defeats pruning + index-only

basics

~20 s

Pushdown moves a filter as close to the data source as possible - below joins and into scans - so fewer rows travel up the plan. Projection pushdown does the same for columns: read and carry only the columns actually needed. Both cut I/O, memory and CPU.

solid answer

~60 s

A plan is a tree of operators; rows flow upward. **Predicate pushdown** relocates a filter from where it was written (typically a top-level `WHERE`) down to the earliest operator that can evaluate it - below joins and aggregations, into the scan itself, or into a remote/storage layer. The gain compounds: filtering before a join shrinks the join's input, so the join builds a smaller hash table, produces fewer rows, and every operator above it does less work. A predicate evaluated inside an index or storage scan may also avoid reading pages at all. **Projection pushdown** (column pruning) is the same idea on the column axis: determine the columns actually referenced anywhere above, and read only those. On a column-store or a remote source this can be a 10x reduction in bytes read; on a row store it still shrinks tuple width, sort and hash memory, and network transfer. Legality constraints matter: a filter cannot be pushed below the null-supplying side of an outer join without changing results, and it cannot be pushed past an aggregate unless it references only grouping columns.

code

text · 8 lines
text
pushed down:
  Hash Join
    -> Index Scan on orders  (Index Cond: created_at >= '2026-01-01')
    -> Hash -> Seq Scan on customers

not pushed:
  Filter: (orders.created_at >= '2026-01-01')
    -> Hash Join   <- join processes all rows first

go deeper

for a junior

State the intent - filter and project as early as possible so fewer, narrower rows flow up - with one concrete example.

for a middle

Add the legality boundaries (outer joins, aggregates, LIMIT) and the plan evidence that pushdown happened.

for a senior

Connect pushdown to partition elimination, index-only access, remote/federated filters and cost-model choices about expensive predicates.

for a principal

Discuss designing schemas, views and storage layouts so pushdown is possible by construction - partition keys, mergeable views, columnar layouts - and where fences are deliberately introduced.

## The plan as a pipeline An execution plan is a tree of operators - scans at the leaves, then joins, aggregations, sorts, and a final projection. Rows flow from leaves to root. The cost of every operator is roughly proportional to the number and width of the rows it receives. That single observation drives the whole family of pushdown rewrites: **discard rows and columns as early as possible.** ## Predicate pushdown SQL lets you write filters where they read best - usually a flat `WHERE` at the top. The rewriter is free to move each conjunct downward, as long as results are unchanged. Concretely: - A predicate on a single table moves below the join onto that table's scan. A join of a million-row and a ten-million-row table where a filter reduces one side to a thousand rows becomes a completely different problem. - A predicate that references only grouping keys moves below a `GROUP BY` (below `HAVING`, in other words), reducing the number of groups built. - A predicate can be pushed into a scan as a *storage-level* filter: an index range condition, a partition-elimination condition, a column-store min/max skip, or a filter shipped to a remote source in a federated query. Each step is a strict improvement in expected cost as long as the predicate itself is cheap to evaluate. Cost models do account for expensive predicates - a filter calling a costly function may be deliberately evaluated late, after cheaper filters have reduced cardinality. ## Projection pushdown / column pruning The rewriter computes, for each operator, the set of columns actually required by its parents, and prunes everything else. Effects: - Narrower tuples mean more rows per memory page, smaller hash tables and sort runs, and fewer spills to disk. - On column stores or Parquet-like formats, unread columns are simply never fetched. - Over a network boundary (federated query, remote table, storage-disaggregated engine) it directly reduces bytes on the wire. - It is why `SELECT *` costs more than naming columns even when the row count is identical: it defeats pruning, and it can also prevent a covering-index-only access path because a needed column is not in the index. ## Legality: when a predicate may not move Pushdown is a rewrite, so it must be semantics-preserving: 1. **Outer joins.** A filter on the null-supplying side cannot be pushed below the join. Applied before the join it removes inner rows and the outer row survives NULL-extended; applied after it removes the whole outer row. Those are different results. (Pushing a filter into the *preserved* side is fine.) 2. **Aggregation boundary.** A predicate over an aggregate result (`HAVING SUM(x) > 100`) cannot move below the aggregation; one over grouping columns can. 3. **Row-limiting and ordering boundaries.** Pushing a filter below a `LIMIT` changes which rows the limit picks. 4. **Set operations and DISTINCT.** Filters generally push through `UNION ALL` per branch; care is needed with `UNION` de-duplication and window functions, whose frames change when input rows disappear. 5. **Volatile or side-effecting predicates.** Their evaluation count is observable, so the engine restricts movement. These boundaries are often called *optimization fences*: operators past which the rewriter cannot freely move things. ## Reading it in a plan You want to see the predicate attached to the scan or index access, not sitting as a filter above a join. Tell-tale signs of failed pushdown: a join whose input row count is huge while the final result is tiny; a filter operator directly under the root; a remote scan returning far more rows than the query keeps. ## Practical levers - Write predicates in a form the rewriter can analyze: compare a bare column to a constant or parameter rather than wrapping the column in a function. - Prefer explicit column lists over `SELECT *` so pruning and index-only access stay available. - Understand that pushing a filter *through* a view or derived table depends on that view being mergeable - a view containing `DISTINCT`, a window function, or a `LIMIT` becomes a fence and the filter stays above it. - On partitioned tables, a pushed-down predicate on the partition key is what enables partition elimination, often the single largest win available.

  • Name a case where pushing a WHERE predicate below a join would give a wrong answer.
    A predicate on the null-supplying side of a LEFT JOIN. If you push `b.status = 'X'` into the scan of b, unmatched a-rows still survive with NULL b-columns; if you keep it above the join, those NULL-extended rows are removed because the comparison is UNKNOWN. The two results differ, so the rewriter must keep it above - unless it instead converts the outer join to an inner join, which is a separate legal rewrite.
  • Why does SELECT * cost more than naming the columns even when the same rows are returned?
    It defeats projection pushdown: the engine must read and carry every column, widening tuples, enlarging sort and hash memory, and increasing bytes read and transferred. It can also disqualify an index-only scan, because the index no longer covers all requested columns, forcing table lookups.

Sorting the mail at the depot instead of carrying every parcel to the office and discarding most of it there.

saying these in an interview costs you the question

  • Believing WHERE is always evaluated after the joins because that is where it is written
  • Claiming any predicate can be pushed below an outer join
  • Ignoring projection pushdown entirely and treating pushdown as rows-only
  • Assuming HAVING is just a WHERE that runs later and can always be pushed
  • Thinking pushdown is a storage-engine feature rather than a rewrite-phase transform

context