DeepConcepts

Databricks / query optimization / joins / file pruning

Dynamic File Pruning

The misconception

That a selective join produces a selective scan: if the dimension filter matches four thousand rows out of two hundred thousand, dynamic file pruning will read roughly four thousand rows' worth of files. What is pushed down is a set of key values, and a file survives if any one of them falls between that file's recorded minimum and maximum for the join key. Four thousand values scattered evenly across the key domain therefore keep every file alive on a perfectly Z-ordered table, while the same four thousand values in one contiguous block keep five. The two failures that follow are both silent: when the join key sits past delta.dataSkippingNumIndexedCols there is no min/max to test and nothing can be excluded, and when the build side outgrows spark.sql.autoBroadcastJoinThreshold Spark replaces the pruning subquery with a literal true. In both cases the physical plan still shows a dynamicpruning expression on the scan and the query profile still reports the feature as applied.

16 min

Dynamic file pruning does not filter your fact table by the rows the join matched. It takes the join-key values found on the small side of the join, tests each one against the minimum and maximum value recorded for every file on the big side, and keeps any file whose range contains at least one of them. How many files that leaves has almost nothing to do with how selective your join was.

The setup is the ordinary star-schema query. A fact table, events, holds 192 million rows in 160 Parquet files. A dimension table, customers, holds 200,000 rows. You filter the dimension, join on customer_id, and you would like Databricks to read only the parts of events that can possibly match.

SELECT e.event_type, count(*)
FROM   events e
JOIN   customers c ON e.customer_id = c.customer_id
WHERE  c.tier = 'enterprise'
GROUP  BY e.event_type

Ordinary file skipping cannot help here. Skipping compares a predicate against the per-file minimum and maximum values stored in the table's _delta_log, and that needs a constant. c.tier = 'enterprise' is a constant, but it is a constant about the wrong table. Which customer_id values it implies is not known until the dimension has actually been read. Dynamic file pruning closes that gap: it runs the small side first, collects the key values it produced, and pushes them into the file filter of the big side before the big side opens a single file.

Two names for the rest of this lesson, because the documentation uses them and they are backwards from how most people say it. The build side is the small table, the one read first to build a hash table — customers here. The probe side is the big table whose rows are then looked up in it, and whose files are the ones being pruned — events.

Below is that pipeline. The events table has been Z-ordered on customer_id, which is the textbook advice, and the dimension filter matches 4,024 rows — two percent of the customers. Read the panel before touching anything.

clustering on the join key is how strongly the physical row order of events follows customer_id. At 100% the table has just been OPTIMIZE … ZORDER BY (customer_id)d. At 0% it is in arrival order, which for an event stream means customer_id is scattered at random through every file.

files read
files pruned
scanned
rows scanned
key values pushed
pruning applied?
Every file's recorded key range, against the values pushed down

One row per file, ordered as they sit in the table; the bar spans that file's recorded minValues to maxValues for the join key. read · pruned. The strip underneath is where the pushed key values fall in the same domain, in 240 buckets, with bar height set to the square root of the count so that a single value stays visible.

The layout is simulated; the pruning rule is the real one. Each file is represented by 150 sampled rows whose measured minimum and maximum become that file's statistics, so at low clustering a real 1.2-million-row file would span an even wider range than the panel draws — the effect is understated, never overstated. Everything after that is the actual test Delta performs: a file is skipped if and only if no pushed value v satisfies minValues <= v <= maxValues. The three gating conditions are the documented ones — deltaTableSizeThreshold 10,000,000,000 bytes and deltaTableFilesThreshold 10 from the current documentation, and "The join type is INNER or LEFT-SEMI" with "The join strategy is BROADCAST HASH JOIN" from the 2020 announcement, which the current page no longer repeats. Files are a fixed 128 MiB and rows per file a fixed 1.2 million, so byte and row totals are illustrative arithmetic, not a benchmark.

160 files read out of 160. Nothing was pruned. The table is perfectly Z-ordered on exactly the column being joined, the dimension filter threw away 98% of the customers, and the scan still touched all 21.47 GB.

Now change nothing except the filter on customers, from c.tier to c.signup_date BETWEEN …. Same table, same layout, same 4,024 matching rows. Files read drops to 5, and rows scanned from 192 million to 6 million. The third option, six industry blocks, lands at 10.

The join did not become more selective. What changed is where the matching customer_id values sit relative to each other, and that is the only thing dynamic file pruning can respond to.

Why the count of matching rows tells you nothing

Look at the strip under the map in each case. With c.tier = 'enterprise' the pushed values are spread across the whole customer_id domain, roughly one every 50 ids, because tier has nothing to do with when a customer signed up and ids are issued in signup order. With the date range they are one solid block, because ids are issued in signup order and the predicate is on signup date.

Against that, the map shows what Z-ordering bought. At 100% clustering the median file covers a customer_id range 1,240 wide out of 200,000 — each file holds about 0.6% of the key space, which is as narrow as 160 files can be. That is a good layout by any measure. It is also irrelevant when the pushed values are spread out, because a file is kept if any single one of them falls inside its range, and with 4,024 values spaced 50 apart, every 1,240-wide window contains about 25 of them.

Set the filter back to c.tier and walk rows it matches up from zero. The saturation is abrupt:

  • 1 matching row → 1 file read.
  • 39 → 39 files. 72 → 68 files. Every value is landing in its own file.
  • 132 → 116 files. 243 → 155 files.
  • 447 → all 160 files. Every file now contains at least one wanted id.

Under perfect clustering, files read is approximately the smaller of the number of distinct pushed values and the number of files. Once your dimension filter matches more rows than the fact table has files, and those rows are spread across the key domain, pruning is finished — and 447 out of 200,000 customers is a filter almost nobody would describe as unselective. The 4,024 of the default is nine times past the point of no return.

This is the part that inverts the usual intuition. Making the dimension filter tighter helps only until you cross below the file count. Making the values closer together helps without limit. Those are different properties and only the second one is under the control of your table layout.

The contiguous case shows the other half. Switch the filter on customers back to the date range and walk the same slider from the bottom: 1 row reads 1 file, 132 rows still read 1, 447 read 2, 2,790 read 3, 4,024 read 5, and 9,457 read 9. Here the count really does drive the answer, because contiguous values occupy a contiguous stretch of the domain and therefore a contiguous run of files. There is no saturation cliff at 447 the way there was for c.tier.

One more notch, 10,685 rows, and the readout goes to all 160. That is not saturation either — the pruning stopped being attempted, and the section after next is about why. Note what it means for the ladder above: on this table the contiguous case never gets to demonstrate its own ceiling, because a different limit arrives at 10,240 matched rows and takes the feature away entirely.

What is actually pushed down

Spark builds this in the optimizer as a DynamicPruningSubquery attached to the probe-side scan. At planning time PlanDynamicPruningFilters looks for a broadcast hash join on the same keys whose broadcast exchange it can reuse. If it finds one, the subquery becomes:

DynamicPruningExpression(InSubqueryExec(customer_id, SubqueryBroadcastExec))

SubqueryBroadcastExec waits on the broadcast the join was going to build anyway, pulls the join-key column out of the hash relation, and hands the scan a list of values. That reuse is the whole trick: the build side is read exactly once and serves both the join and the pruning. The subquery is named dynamicpruning#<exprId>, which is the string to look for in a physical plan.

Databricks' contribution is what happens next. In open-source Spark the value list prunes partition directories — it can only eliminate data whose key is encoded in a path. Databricks pushes the same list into the Delta scan instead, where it is tested against the minValues and maxValues that every add action in the log carries for the first 32 columns. The 2020 announcement puts it in one sentence: "a dynamic filter is created from the build side of the join and passed into the SCAN operation." That is why the feature works on a non-partitioned column, and it is the reason it is worth understanding separately from partition pruning.

The test each file gets is the ordinary interval test. Given pushed values V and a file with recorded bounds [min, max], the file can be skipped only if no member of V lies in that interval. The decision log in the panel prints one worked example of each outcome on every change, with the actual boundary values and the actual nearest pushed value on either side, so you can check the arithmetic rather than take it.

Two consequences follow directly and neither is obvious from the docs. The filter is a set of values, so its cost grows with the size of the build side — this is why the feature is tied to a broadcast in the first place. And the test is per-file and independent, so pruning has no way to exploit the fact that your 4,024 ids came from one contiguous predicate. If the layout does not put them in few files, nothing downstream can.

The two ways it silently does nothing

Both of these leave a dynamicpruning expression sitting in the physical plan. Neither logs a warning.

No statistics for the join key. Put rows it matches back to 4,024, where the date-range filter was reading 5 files, then switch the join key in the schema to column 41 of 60. Files read jumps from 5 to 160, the verdict readout says no bounds, and the map goes flat — every bar becomes full width, because unknown bounds are unbounded bounds. Delta records min/max only for columns whose position is below delta.dataSkippingNumIndexedCols, and the default in DataSkippingReader.scala is DATA_SKIPPING_NUM_INDEXED_COLS_DEFAULT_VALUE = 32. A column at position 41 has no minValues entry, a file whose bounds are unknown could contain anything, and a pruning rule that must never drop a matching row has to keep it.

Three ways to land here without noticing. Someone adds columns to the middle of the schema and pushes your join key past 32. Someone sets delta.dataSkippingStatsColumns to an explicit list — the Delta source is blunt that "the column stats not mentioned by this config will be ignored even if they exist", so the statistics can be physically present in the log and still unused. Or the key is a long string: the third option in that dropdown is a 44-character order_ref whose first 32 characters are a constant prefix, and spark.databricks.io.skipping.stringPrefixLength defaults to 32, so substring(min(c), 0, 32) records the identical value for every file in the table. The bounds exist, they are correct, and they separate nothing.

The broadcast went away. Put the join key in the schema back to column 3 of 60 first — the paragraph above left it at column 41, and with no statistics every reading below is 160 whatever else you do. Then set the filter to the date range and rows it matches to 9,457. Nine files read. Move it one notch, to 10,685 rows. All 160.

The estimated build side crossed spark.sql.autoBroadcastJoinThreshold — 9.24 MB to 10.43 MB against a 10 MB default — so the join is planned as a sort-merge join instead. There is now no broadcast exchange to reuse, and PlanDynamicPruningFilters takes its other branch: DynamicPruningExpression(Literal.TrueLiteral). A filter that is the literal true is still a filter in the plan. It removes nothing. Tick force the broadcast with a hint and the same 10,685-row query reads 10 files again.

Making a dimension filter slightly less selective can therefore cost you an order of magnitude, which is not a shape anyone expects from a query optimizer. It also means an out-of-date sizeInBytes on the dimension can switch dynamic file pruning off by mistake: the planner only has an estimate, and if it guesses the build side is 12 MB when it is really 2 MB you lose both the broadcast and the pruning. When adaptive query execution converts a sort-merge join back to a broadcast at runtime, it does so after the shuffle has already been written — too late to prune the scan that fed it.

The join type is the third condition and it is a hard one. Switch join type to LEFT OUTER JOIN: 160 files. A left outer join with events on the left must emit every fact row whether or not it matched, so no fact file can be excluded on the strength of the dimension. The 2020 announcement lists the condition as "The join type is INNER or LEFT-SEMI"; the current documentation page dropped it, which is worth knowing before you go looking for it. Spark's own rule is the authority underneath: canPruneLeft in joins.scala returns true only for Inner, LeftSemi and RightOuter, and the fact table is on the left here. LEFT SEMI in the dropdown behaves identically to INNER, because both discard non-matching fact rows.

The thresholds, and the cliff nobody expects

Put the join type back to INNER, untick the broadcast hint and put rows it matches back to 4,024 — the last two paragraphs moved all three. Leave the filter on the date range and drag files in events down to 74. The table is 9.93 GB and all 74 files are read. Now put it up by one, to 75 files and 10.07 GB. Two files are read.

Adding 128 MB to a table reduced what the query scans from 9.93 GB to 0.27 GB, because spark.databricks.optimizer.deltaTableSizeThreshold is "the minimum size (in bytes) of the Delta table on the probe side of the join required to trigger dynamic file pruning", and its default is 10,000,000,000 bytes. Below it the optimizer does not attempt pruning at all — the reasoning being that on a small table the cost of computing the filter outweighs the scan it would save, which is defensible on average and surprising in the specific case.

deltaTableFilesThreshold is the second gate, defaulting to 10 files. Which of the two binds first is decided by your file size, and this is worth a moment because the defaults encode an assumption. 10,000,000,000 bytes divided by 10 files is 1 GB per file, and optimize.maxFileSize defaults to 1 GiB — so on a table written with default-sized files the two gates open within a few percent of each other. On a table of 128 MiB files, the file gate opens at 11 files and the size gate not until 75, so the file gate never binds and the size gate binds 65 files later than you would guess from reading about it. On a table of thousands of tiny files the size gate is the only one that ever matters.

Both defaults are session settings you can lower. The honest reason to do so is a table just under the line whose queries are latency-sensitive; the dishonest one is to make a benchmark look better on a table small enough that it would have been cached anyway.

Raising the ceiling

Everything above says pruning is bounded by the layout. So change the layout. Put files in events back to 160 — the threshold section left it at 11, where pruning is not attempted at all — keep the date-range filter and the match count at 4,024, and sweep clustering on the join key from 0 to 100:

  • 0% — arrival order. 160 files read. The median file spans 197,894 of the 200,000 possible ids, so every file's range contains the whole block. This is what an un-optimized event table looks like and it is why a point lookup on a single customer, at the bottom of the match slider, also reads all 160 files.
  • 50% — 113 files. Median range 140,383.
  • 80% — 44 files. Median range 49,467.
  • 100% — 5 files. Median range 1,240.

The curve is steep at the top, which matters practically: partial clustering buys you very little. Going from 0% to 50% removes 29% of the work; going from 90% to 100% removes another 11% of the original but cuts the remaining work by more than three quarters. A table that is "roughly sorted" by the join key is not most of the way there.

This is where Databricks' own advice to use liquid clustering to maximise dynamic file pruning comes from, and it is the right advice for a reason that is easy to miss: clustering has to survive your writes. A Z-ordered table degrades towards arrival order with every append, so the 100% row above is the state of the table immediately after OPTIMIZE and not the state your 3 a.m. query runs against. Liquid clustering re-clusters incrementally as data lands, which keeps you near the top of that curve instead of sliding down it between maintenance windows.

One interaction to be aware of if you have deletion vectors on. The Delta protocol lets a file carry tightBounds: false, meaning the recorded minimum is merely "less than or equal to all valid values" rather than equal to the smallest one. Deleting the only enterprise customer in a file does not narrow that file's range, so it keeps qualifying for pruning tests it can no longer satisfy. The bounds stay correct — they are still safe to skip on — they just stop being tight, and only a rewrite makes them tight again.

Checking it on a real system

The diagnosis is always the same three questions in the same order: was pruning attempted, did it have statistics to work with, and was the layout capable of using them. Answer them separately or you will fix the wrong one.

Was it attempted? Run DESCRIBE DETAIL events and read sizeInBytes and numFiles. If sizeInBytes is under 10,000,000,000 or numFiles is 10 or fewer, dynamic file pruning was never in play and no amount of clustering will change that. Then check the join: EXPLAIN FORMATTED and look for BroadcastHashJoin rather than SortMergeJoin, and for a scan filter containing dynamicpruning#. A dynamicpruning expression that reads simply true means the broadcast was not reusable and the filter was planned away — that is the Literal.TrueLiteral branch, and the fix is a BROADCAST hint or a corrected sizeInBytes, not a table rewrite.

Did it have statistics? Query the log directly. This returns one row per live file with the recorded bounds for your join key:

SELECT count(*)                                  AS files,
       count(get_json_object(add.stats, '$.minValues.customer_id')) AS with_stats
FROM   json.`/path/to/events/_delta_log/*.json`
WHERE  add IS NOT NULL

If with_stats is 0 the column is outside the statistics set: check its ordinal position against SHOW TBLPROPERTIES events for delta.dataSkippingNumIndexedCols and delta.dataSkippingStatsColumns. Changing either property does not recompute anything for files already written; on Databricks Runtime 14.3 LTS and above ANALYZE TABLE events COMPUTE DELTA STATISTICS backfills them, and otherwise the files have to be rewritten. If the values are present but identical everywhere, you have the truncated-string case or a layout problem, not a statistics problem.

Was the layout capable? Take the same query and compute the two numbers the panel shows. From the log, the median width of maxValues.customer_id - minValues.customer_id across files. From the dimension, count(DISTINCT customer_id) under your filter and the span between its smallest and largest value. If the distinct count exceeds numFiles and the values span most of the domain, the join is at its pruning ceiling and clustering the fact table harder will not move it. Cluster on something the predicate is actually contiguous in instead — often the date column the dimension filter is really about.

The number that confirms it. In the Databricks query profile, open the scan node for the fact table and compare files read against the table's file count; the filter icon on the metric shows the percent pruned. Do this on the scan node, not the whole query, because a query that spends its time in the aggregation will look fine in wall-clock terms right up until the fact table doubles. A useful habit is to record files read alongside the query, so that the day pruning silently stops — a schema change, a dimension that grew past the broadcast threshold, an OPTIMIZE that stopped running — you see a step change in a counter rather than a gradual complaint about latency.

Two things this lesson does not cover but which will bite in the same area. Dynamic file pruning in MERGE, UPDATE and DELETE requires Photon-enabled compute; without it those statements do not get it at all, which is the usual explanation for a MERGE with a 200-row source rewriting a whole target. And dynamic file pruning is a Databricks Runtime feature, not part of the Delta Lake protocol — an open-source Spark job against the same table gets Spark's partition-level pruning only, which is why advice that works in a notebook can fail in a self-managed cluster reading the identical files.

A 40 TB fact table in 20,000 files is Z-ordered on account_id, giving each file a narrow, non-overlapping range. Your query joins it to a dimension filtered down to 900 accounts, spread across the whole id space. The scan reads 900 of the 20,000 files — a 96% prune, and still 1.8 TB, on a join that matches 900 dimension rows. What is the smallest change that helps most?

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.