PySpark / Performance Fig_05  ·  Lesson 05 / 14
Fig_05 · Adaptive Query Execution · v1.0 OSS Spark + Azure Databricks

Adaptive Query Execution

The plan that rewrites itself at runtime

Catalyst builds the plan once, from stats that a filter or a groupBy quietly makes wrong. AQE waits for a shuffle to finish, reads the real partition sizes, and re-optimizes the rest of the plan — coalescing the tiny, splitting the skewed, broadcasting what turned out small. The estimate is replaced by the truth, mid-flight.

default-on since Spark 3.2 advisory 64 MB skew factor 5× / 256 MB verified Jun 2026

01 · What it is

Re-planning at runtime, on real numbers

Adaptive Query Execution (AQE) is Spark re-optimizing the physical plan at runtime, using the actual statistics it collects after each shuffle — instead of trusting the static, estimate-based plan Catalyst built before the job ran.

A non-adaptive plan is decided once, from table stats and file sizes that are routinely wrong: a filter shrinks a table, a groupBy makes uneven partitions, a join side turns out tiny after a WHERE. AQE waits until a shuffle stage finishes, reads the real per-partition sizes, then rewrites whatever is left of the plan to match reality.

analogy A non-adaptive plan is a route printed before you leave; AQE is live GPS that reroutes you at every junction once it sees the actual traffic. The deep dive below breaks down exactly what it reroutes; for now just hold the shape:

The one rule to remember AQE needs real shuffle statistics to act, so it only re-optimizes at stage boundaries (after a shuffle). It is on by default — leave it on, and let it coalesce / split / switch before you hand-tune spark.sql.shuffle.partitions.
What AQE does

Re-optimizes the physical plan at each shuffle boundary using the real per-partition sizes it just measured.

Let AQE adapt

Keep it on and let it coalesce tiny partitions, split skew, and switch joins from the truth at runtime.

Hand-tuned static plan

A global shuffle.partitions or forced broadcast() freezes the plan on stale estimates AQE would have fixed.

02 · Why it matters

The modern default answer to "my shuffle is slow"

03 · How it works — deep dive

The framework, then the three things it does

Everything AQE does follows one loop: run a stage up to a shuffle, measure what really came out, re-optimize the rest of the plan, submit the next stage — repeat at every boundary. So the useful way to understand it is: (1) that measure-and-replan framework, then (2) the three specific rewrites it applies. Each idea below is explained here once — the figures show it, the code verifies it, and later sections cross-reference it, but don't re-explain it.

3.1 · The framework — measure, then re-plan

The one version fact people get wrong AQE was introduced in Spark 1.6.0, but its spark.sql.adaptive.enabled default only flipped to true in Spark 3.2.0 (SPARK-33679). "Since 1.6" is when it existed; "3.2" is when it turned on by itself. On Azure Databricks it's on by default since DBR 7.3 LTS. Don't conflate the two.

The three rewrites at a glance before the detail — each is unpacked in 3.2–3.4 below:

FeatureWhat it changes at runtimeConfig / threshold
CoalesceMerges adjacent tiny post-shuffle partitions into fewer, fuller onescoalescePartitions.enabled; advisory 64 MB, floor 1 MB
Skew splitSplits one enormous partition into balanced sub-partitionsskewJoin.enabled; > 5× median AND > 256 MB
Join switchFlips SortMergeJoin → BroadcastHashJoin once a side proves smallruntime broadcast switch (OSS 10 MB → DBX 30 MB)

3.2 · Rewrite one — coalesce tiny shuffle partitions

analogy Instead of mailing 200 near-empty envelopes, AQE combines them into a few full ones — fewer trips, same contents.

use case A daily aggregation outputting a few hundred MB: without AQE it runs 200 tasks and writes 200 tiny files; with coalesce it runs ~5 balanced tasks and writes ~5 files.

3.3 · Rewrite two — split a skewed partition

analogy If one checkout lane has 500 shoppers and the others have 5, AQE opens extra lanes for that one queue so everyone finishes together.

use case transactions ⋈ accounts where one "house" account holds 40% of rows — that account_id partition is 5× the median and well over 256 MB, so AQE splits it.

3.4 · Rewrite three — switch sort-merge → broadcast at runtime

analogy You packed for a long road trip (sort-merge), then learn the destination is round the corner — so you ditch the heavy plan and walk (broadcast).

use case events ⋈ dim_country where dim_country is "big" in the catalog but a WHERE active = true reduces it to a few MB — Catalyst plans sort-merge; AQE flips it to broadcast.

Photon ≠ AQE Photon is Databricks' native, vectorized execution engine (the layer that runs operators); AQE works at the planning layer (it rewrites the plan). They are complementary — Photon executes whatever plan AQE finalizes. Keep both on; one does not replace the other. This is the single distinction interviewers test most here.

3.5 · The Databricks fourth feature

Open-source Spark documents three AQE features (the three rewrites above). Azure Databricks lists a fourth: detect & propagate empty relations — when a stage produces zero rows at runtime, AQE prunes the dependent branches (e.g. an inner join with one empty side becomes empty without doing the rest of the work). Databricks also exposes a dedicated, higher runtime broadcast switch — see §4 and §5. Don't claim empty-relation propagation as an OSS feature.

Figure A · one shuffle boundary, three rewrites

Step the plan as AQE re-optimizes it

This figure isolates what happens to the partitions at a single shuffle boundary. Step through it: 200 tiny partitions coalesce toward 64 MB, then a hot partition that clears both bars (> 5× median and > 256 MB) splits, then the join flips sort-merge → broadcast once the real size is small. (Where it lands across the nodes is Figure B.) Use Prev / Play / Next.

coalesce → split → switch

A groupBy(...).sum() feeding ⋈ accounts: median partition ≈ 3 MB, one hot partition ≈ 320 MB, the accounts side ≈ 22 MB after a filter.

Post-shuffle partitions (200, mostly tiny)
200
post-shuffle partitions
200
tasks scheduled
—
AQE action
step 1 / 4

Simplified illustration — 60 bars stand in for 200 partitions; real coalescing merges by contiguous byte size and skew split weighs matching-side replication.

04 · The code — and how to verify it

Each config, with its proof in the plan

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

Simple reading rule For each config, ask one question first: "Is this enabling AQE (or tuning a threshold it reads), or is it overriding what AQE would decide?" Enabling and threshold configs let it adapt from real stats; a global shuffle.partitions or a forced broadcast() pins the plan and pre-empts it.

Confirm AQE is engaged

python# Master switch — on by default since Spark 3.2.0 / DBR 7.3+.
spark.conf.get("spark.sql.adaptive.enabled")   # -> 'true'

# VERIFY — an adaptive plan roots at AdaptiveSparkPlan; after a run it flips to isFinalPlan=true.
df.explain(mode="formatted")
#   == Physical Plan ==
#   AdaptiveSparkPlan isFinalPlan=true        <- AQE is engaged
#   +- == Final Plan ==  *(...) ... AQEShuffleRead coalesced

Coalesce — tune the target and the floor

python# Defaults shown explicitly (and to tune the target).
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")               # default true
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", 64*1024*1024)   # 64 MB target
spark.conf.set("spark.sql.adaptive.coalescePartitions.minPartitionSize", 1*1024*1024)  # 1 MB floor

agg = sales.groupBy("store_id").sum("amount")   # a shuffle (wide dependency)

# VERIFY — post-shuffle partition count collapses from 200 toward a handful.
print(agg.rdd.getNumPartitions())   # AQE-coalesced count (e.g. ~5), not 200
agg.explain(mode="formatted")       # look for: AQEShuffleRead coalesced

Skew join — the lever set (needs BOTH conditions)

pythonspark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")               # default true
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5.0")  # > 5x median ...
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes",
               256*1024*1024)                                    # ... AND > 256 MB

joined = transactions.join(accounts, "account_id")   # sort-merge join on a skewed key

# VERIFY — the plan shows the skew was detected and split.
joined.explain(mode="formatted")    # look for: AQEShuffleRead ... skewed=true
# Spark UI (Stages tab): max task time collapses toward the median.

Join switch — OSS static vs Databricks runtime threshold

python# OSS static threshold — used by Catalyst BEFORE runtime:
spark.conf.get("spark.sql.autoBroadcastJoinThreshold")             # -> '10485760' (10 MB)
# Databricks AQE RUNTIME switch — a higher, dedicated bar:
spark.conf.get("spark.databricks.adaptive.autoBroadcastJoinThreshold")  # -> 30 MB (Databricks)

joined = events.join(dim_country.where("active = true"), "country_code")

# VERIFY — the FINAL (post-run) plan flips SortMergeJoin -> BroadcastHashJoin.
joined.explain(mode="formatted")
#   AdaptiveSparkPlan isFinalPlan=true
#   +- == Final Plan ==   BroadcastHashJoin ...   <- AQE switched it
#   +- == Initial Plan == SortMergeJoin ...        <- stale-estimate plan

Same configs from Spark SQL

sqlSET spark.sql.adaptive.enabled = true;
SET spark.sql.adaptive.coalescePartitions.enabled = true;
SET spark.sql.adaptive.skewJoin.enabled = true;

-- VERIFY — read the adaptive plan (AdaptiveSparkPlan root):
EXPLAIN FORMATTED
SELECT account_id, count(*) FROM transactions GROUP BY account_id;
Stop hand-guessing shuffle.partitions A globally small spark.sql.shuffle.partitions (say 8) starves the next big-result query of parallelism; a big one wastes tasks on small results. Leave it as an upper bound (200) and let coalesce size each query from real stats — verify with getNumPartitions(), which now adapts per query.

Figure B · where it runs

See it re-plan across the cluster

This figure isolates a different thing from Figure A: not what happens to the partitions, but where the driver and executors do the work. Step through the same job on 1 driver + 3 executors — the driver re-plans at each boundary from the stats the executors report, coalescing the tiny partitions, splitting the hot one across nodes, and flipping the join to a broadcast. Use Prev / Play / Next.

AQE on the driver + 3 executors

Dummy job: tx.groupBy("account_id").sum() ⋈ accounts. Chips are post-shuffle partitions — tiny ones plus one hot/skewed key (red).

Driver re-plans at each boundary

Holds the adaptive plan · reads map-output stats after the shuffle

Executor 1 4 cores

 

Executor 2 4 cores

 

Executor 3 4 cores

 
step 1 / 5
Dummy PySpark
# AQE on by default since Spark 3.2.0 / DBR 7.3+spark.conf.set("spark.sql.adaptive.enabled", "true")# advisory 64 MB · skew 5x median & > 256 MBagg = tx.groupBy("account_id").sum("amt") # shufflejoined = agg.join(accounts, "account_id") # SMJ?joined.write.format("delta").save(path)
Runtime stats AQE reads
post-shuffle partitions = 12
median size ≈ 3 MB
advisory target = 64 MB
hot account_id ≈ 320 MB
skew test 320 > 5x median & > 256 MB
accounts side ≈ 22 MB (post-filter)

Simplified illustration — sizes are illustrative; real coalescing merges by contiguous byte size and skew split weighs matching-side replication.

05 · The three rewrites at a glance

Which rewrite, and what proves it fired

Read this by the problem you're seeing — it answers "which AQE rewrite is this, and what's the plan/UI signal?" at a glance. (The why is §3; this is the which.)

DimensionCoalesceSplit (skew join)Join switch
Problem it fixesToo many tiny partitionsOne skewed stragglerSort-merge on a stale estimate
Lever (default on)coalescePartitions.enabledskewJoin.enabledruntime broadcast threshold
Key numberadvisory 64 MB, min 1 MB> 5× median AND > 256 MBOSS 10 MB static → DBX 30 MB runtime
What it doesMerges adjacent to ~64 MBSplits the hot partitionFlips SortMergeJoin → BroadcastHashJoin
NeedsA shuffle's real sizesSort-merge join + real sizesReal post-shuffle size of a side
Plan / UI signalAQEShuffleRead coalescedAQEShuffleRead skewed; max task ≈ medianfinal = BroadcastHashJoin, no big-side Exchange
When it can't helpOutput truly needs high parallelismSkew too extreme → salt (Lesson 08)Small side still too big to broadcast

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of the three rewrites:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
Saying "AQE is default-on since 1.6"Conflates when it existed with when it turned itself on.Introduced in 1.6.0; default-true only in 3.2.0 (SPARK-33679).
Disabling AQE to "control" the planThrows away the coalesce/split/switch that usually beats hand-tuning.Leave AQE on; scope a broadcast() hint if one join needs fixing.
Expecting a skew split on one thresholdA partition over the factor but under 256 MB is never split.Needs > 5× median and > 256 MB; else salt (Lesson 08).
Confusing OSS 10 MB with DBX 30 MB switchThe static up-front bar is not the runtime switch.autoBroadcastJoinThreshold is up-front; databricks.adaptive.autoBroadcastJoinThreshold is runtime.
Thinking Photon replaces AQETreats an execution engine as a planner.Photon executes; AQE plans — keep both on, different layers.
Trusting explain() before the runThe initial plan may show isFinalPlan=false / a stale strategy.Look for isFinalPlan=true or the Spark UI's post-run DAG.

08 · References

Sources