PySpark / Performance Fig_07  ·  Lesson 07 / 14
Fig_07 · Partition pruning · v1.0 OSS Spark + Azure Databricks

Partition pruning

Static pruning · dynamic partition pruning (DPP)

The cheapest data to process is the data you never read. Pruning skips whole directories of files — statically when you filter on a partition column, dynamically when you join a partitioned fact to a filtered dimension. Read less before you optimise anything else.

Spark 3.0+ / DBR LTS DPP default true signal PartitionFilters verified Jun 2026

01 · What it is

Reading only the directories you need

Partition pruning is Spark reading only the on-disk directories a query needs and skipping the rest, instead of scanning every file in a table. When a table is written with partitionBy, each distinct value of the partition column becomes its own directory — and a filter that maps to a subset of those directories lets Spark ignore the others entirely. It comes in two flavours, and the deep dive explains each once:

analogy A partitioned table is a filing cabinet with one labelled drawer per value. Pruning is opening only the drawers your query names and leaving the rest shut — you don't read a folder to decide you didn't want it.

The one rule to remember Pruning skips files, not work-in-flight. Fewer directories read → fewer scan tasks → less of everything downstream, because less data ever entered the pipeline. This is the purest "read less" win in the track.
What pruning does

Reads only the on-disk directories a query needs and skips the rest — before any scan task runs.

Fires — few dirs read

A literal filter on the partition column (WHERE country='US') → Spark opens just the matching directories.

Defeated — full scan

A function on the partition column (year(order_date)=2025) can't map to directory names → the whole table scans.

02 · Why it matters

I/O is usually the first bottleneck

03 · How it works — deep dive

Two meanings of "partition", then static, then dynamic

Almost every partition-pruning bug starts with the word "partition" meaning two different things. So the useful order is: (1) pin down which partition we mean, (2) static pruning at plan time, (3) dynamic pruning at runtime. Each idea is explained here, once — later sections show it, verify it, and help you decide, but don't re-explain it.

3.1 · Two meanings of "partition" — never confuse them

On-disk partition ≠ in-memory partition Pruning operates exclusively on (a), the on-disk directories. Bumping spark.sql.shuffle.partitions changes parallelism and does nothing for file-skipping — it's the wrong "partition." This is the single distinction interviewers test most; hold it firmly.

use case Create the directories with a low-cardinality column: events.write.partitionBy("event_date"). Dashboards filter by date, so every query prunes to a handful of day-directories. Partitioning a high-cardinality column (user_id, order_id) explodes the table into millions of tiny directories — the small-files problem plus heavy driver-side metadata (Lesson 03). Aim for partitions in the hundreds-of-MB-to-GB range.

3.2 · Static partition pruning — decided at plan time

3.3 · Dynamic partition pruning (DPP) — decided at runtime

analogy DPP is a bouncer who first checks the small VIP list (the filtered dimension), then opens only the cloakroom drawers (fact partitions) for names actually on it — instead of opening every drawer and checking each coat afterwards.

use case sales (by order_date, 2 TB) ⋈ date_dim WHERE quarter='Q4-2025' — DPP prunes sales to ~90 day-partitions at runtime instead of scanning all 2 TB.

Reading it in the plan / UI Static → PartitionFilters: [(country = US)] on the Scan node; "partitions read" drops in the Spark UI SQL tab. DPP → PartitionFilters: [dynamicpruningexpression(...)] plus a SubqueryBroadcast / reused broadcast, and the fact Scan reports far fewer files read than the table's total. It composes with column pruning and predicate pushdown, which trim what's read inside the surviving files.

Figure A · which directories open

Watch the directories light up

The grid is a sales table with one on-disk directory per region value. Pick a filter and watch matching directories light up (read) while the rest grey out (skipped) — static pruning. Then flip on DPP and choose which quarter the dimension keeps; the surviving key prunes the fact at runtime. A function-wrapped filter collapses pruning back to a full scan. This figure isolates which drawers open; Figure B shows the same prune running across the nodes.

partition-directory pruner

32 directories — one per region value. A literal filter on the partition column prunes at plan time.

on-disk directories — /sales/region=…
read (hit) skipped (pruned)
—
directories read
—
directories skipped
—
data scanned

Simplified illustration — real pruning lists directories from the catalog before any task runs.

04 · The code — and how to verify it

Three moves, each with its proof

Every technique below is paired with how to prove it worked — the .explain() plan line or the Spark-UI signal. Apply, then verify; never assume.

Simple reading rule When you read a filter, ask one question first: "Does this hit the partition column directly with a literal, or is it wrapped in a function that blocks pruning?" Direct-and-literal → PartitionFilters fires; a function or UDF around the column drops the predicate to Filter/PushedFilters and the whole table scans.

Create on-disk partitions with partitionBy

python# partitionBy writes one directory per distinct order_date value.
# Pick a LOW-cardinality column (date/region) — never user_id/order_id.
(sales
   .write
   .partitionBy("order_date")                 # ← creates /order_date=.../ directories
   .mode("overwrite")
   .saveAsTable("main.pyspark_perf.sales"))    # UC 3-level name; Delta is the default

# VERIFY the table really is partitioned:
spark.sql("DESCRIBE EXTENDED main.pyspark_perf.sales").show(truncate=False)
#   look for "Partition Columns: [order_date]"

Trigger static partition pruning — a literal filter

python# A LITERAL filter on the partition column → plan-time pruning.
one_day = spark.table("main.pyspark_perf.sales").where("order_date = '2025-01-01'")

# VERIFY: the scan node must carry a PartitionFilters line with your literal.
one_day.explain(mode="formatted")
#   (1) Scan ... main.pyspark_perf.sales
#       PartitionFilters: [isnotnull(order_date#..), (order_date#.. = 2025-01-01)]   ✅
#   Spark UI SQL tab: "number of partitions read" = 1 (not the full table)

# ❌ Anti-pattern: a function on the partition column defeats static pruning.
slow = spark.table("main.pyspark_perf.sales").where("year(order_date) = 2025")
# explain() shows it under Filter / PushedFilters, NOT PartitionFilters → full scan.
# Fix — filter the raw column with a literal/range:
#   order_date >= '2025-01-01' AND order_date < '2026-01-01'

Trigger DPP — a partitioned fact ⋈ a filtered dimension

pythonfrom pyspark.sql.functions import col

sales    = spark.table("main.pyspark_perf.sales")      # partitioned by order_date (the join key)
date_dim = spark.table("main.pyspark_perf.date_dim")   # small dimension, broadcastable

# Filter lives on the DIMENSION, not the fact — only DPP can prune the fact here.
q = (sales.join(date_dim, sales.order_date == date_dim.d_date)
          .where(col("d_quarter") == "Q4-2025"))         # ← dim filter

# VERIFY: the fact scan's PartitionFilters carries a dynamicpruningexpression.
q.explain(mode="formatted")
#   (n) Scan ... sales
#       PartitionFilters: [dynamicpruningexpression(order_date#.. IN subquery#..)]   ✅
#   ...a SubqueryBroadcast / ReusedExchange feeds it the surviving dates.
#   Spark UI SQL tab: the `sales` Scan reads only the Q4 partitions.
python# DPP is on by default; A/B it by turning it OFF to see the before-state, then reset.
print(spark.conf.get("spark.sql.optimizer.dynamicPartitionPruning.enabled"))  # 'true'
spark.conf.set("spark.sql.optimizer.dynamicPartitionPruning.enabled", "false")  # demo only
# ...re-run q.explain() → dynamicpruningexpression gone, fact reads ALL partitions ❌
spark.conf.set("spark.sql.optimizer.dynamicPartitionPruning.enabled", "true")   # reset

Figure B · where the prune happens

See DPP run across the cluster

This figure isolates a different thing from Figure A: not which directories, but where and when the prune is decided. Step through a full scan, then static pruning, then DPP across 1 driver + 2 executors. Watch the driver broadcast the filtered dimension's surviving keys at runtime and prune the fact partitions before the executors scan them. Use Prev / Play / Next.

cluster execution lab — pruning the scan

sales partitioned by country · the driver lists directories, executors scan only the survivors.

on-disk partition directories — /sales/country=…

Driver lists dirs · plans

Executor A scan task

Executor B scan task

step 1 / 6
dummy PySpark
what the scan reads

Simplified illustration — directory listing happens on the driver before tasks launch; pruned directories never become scan tasks.

05 · Decision table

Which pruning, and what fires it

Read this by the situation you're in — it answers "will this prune, and how?" at a glance. (The why is §3; this is the which. The last column is the in-memory contrast, not a fourth kind of pruning.)

DimensionStatic pruningDynamic pruning (DPP)In-memory partitions (contrast)
What it skipsOn-disk dirs that don't matchFact dirs not matched by the dim filterNothing — parallelism chunks, not files
When decidedPlan timeRuntime (after the dim filter runs)n/a
Triggered byLiteral filter on the partition columnPartitioned fact ⋈ filtered, broadcastable dimshuffle.partitions / repartition()
ConfigAlways on (no flag)dynamicPartitionPruning.enabled=true (3.0+)shuffle.partitions=200
Plan signalPartitionFilters: [col = lit][dynamicpruningexpression(...)] + SubqueryBroadcastExchange hashpartitioning(...,200)
Main requirementPartition column filtered directlyFact partitioned on join key + dim broadcastable—

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of §3:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
Bumping spark.sql.shuffle.partitions to make pruning happenWrong "partition" — that's parallelism, not file-skipping.Pruning is about PARTITIONED BY directories; leave shuffle.partitions out of it.
Wrapping the partition column in a function (year(order_date)=2025)Catalyst can't match it to directory names → full scan.Filter the raw column: order_date >= '2025-01-01' AND order_date < '2026-01-01'.
Expecting static pruning when the filter is on a dimensionNo literal exists for the fact at plan time.That's DPP's job — make sure its requirements are met.
Over-partitioning a high-cardinality column (user_id)Millions of one-row directories — small files + driver metadata.Partition low-cardinality; bucket or Z-ORDER high-cardinality keys.
Assuming DPP firedIt's silent when it doesn't — you fall back to a full fact scan.Confirm dynamicpruningexpression in .explain() and the reduced "partitions read" in the Spark UI.

08 · References

Sources