The Architecture Recital
What you will be able to do
What the planner does read
The planner starts from real numbers about stored data. For a file-based table that has never been analyzed, it takes the total file size from the directory listing and uses it as the table's sizeInBytes. After ANALYZE TABLE, it reads the stored size and, with column statistics, the distinct count, min, max and null count of each column. Both are facts about the table at rest. Neither says how many rows survive a predicate in this particular query, how the join key is spread, or how large an intermediate result will be once it has been joined, filtered and aggregated.
3 facts the planner cannot know
| Runtime fact | What the static plan assumes | What actually happens |
|---|---|---|
| Selectivity of a filter | Without column statistics, a filter keeps its whole input size | `event_type = 'purchase'` keeps 0.4% of a 2GB table, about 8MB |
| Distribution of the join key | Hash partitioning spreads rows evenly across reducers | A bot account owns 30% of rows, so a single reducer gets 30% of the work |
| Size of each shuffle's output | `spark.sql.shuffle.partitions`, 200 by default, suits every shuffle | 200 reducers over 50MB gives 250KB tasks; over 2TB it gives 10GB tasks |
Watching skew land on a single task
The program below joins 12 orders to 5 users with automatic broadcast turned off, so Spark plans a sort-merge join and shuffles both sides by user_id into 3 reducer partitions. Adaptive execution is turned off as well (spark.sql.adaptive.enabled set to false), because on data this small its coalescing would merge all 3 reducers into 1 and hide the static plan. User 1 owns 8 of the 12 orders. Follow those 8 rows through the shuffle.
spark.conf.set("spark.sql.adaptive.enabled", "false")spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)result = orders.join(users, "user_id").collect()
Every row for user 1 hashes to the same reducer, whichever reducer that turns out to be, because hash partitioning is a pure function of the key. That task handles at least 8 order rows while the other 2 share 4 at most. Nothing in the static plan reacts: the partitioner was fixed at plan time, and it has no way to split a key whose size it never measured. Scale the same shape to 30% of 4 billion rows over 200 reducers and the hot partition holds 1.2 billion rows against about 14 million in each of the others. With adaptive execution on, this join is the case its skew handling is built for: it cuts the oversized partition into several tasks and gives each one the matching users partition. Put the same hot key under groupBy("user_id") with collect_list, which partial aggregation can't shrink, and no rule splits it: one task receives 1.2 billion rows while the other 199 finish in seconds.
Where to look when you suspect the model broke
- Stages tab, task summary: compare the max task duration with the median. A max 20 times the median points at skew, and adding executors will not move it.
- Run df.explain(mode="cost") to print the size and row estimates the planner used, then compare them with each operator's number of output rows on the SQL tab.
- SQL tab, exchange nodes: shuffle bytes written total shows what each shuffle actually moved, the figure the partition count should have been sized for.
- Stages tab, spill columns: a nonzero Spill (Disk) on a reduce stage means its partitions outgrew execution memory.
The query on the table
Walk the estimate the way the planner does. With the cost-based optimizer off, which is the default (spark.sql.cbo.enabled is false), a filter's output is estimated at the size of its input. The planner sees 2GB of events joined to 400MB of users, neither below the 10MB broadcast threshold, and picks a sort-merge join. That plan shuffles both sides by user_id and then shuffles again by country for the aggregation: 3 exchanges and 4 stages.
| Input | Estimated size |
|---|---|
| events after filter | 2GB |
| users | 400MB |
| Join chosen | sort-merge |
| Input | Measured size |
|---|---|
| events after filter | 8MB |
| users | 400MB |
| Join that fits | broadcast hash |
Statistics move the guess, and the guess stays a guess
With column statistics and no histogram, the cost model estimates an equality filter as 1 divided by the column's distinct count. Events has 25 event types, so the estimate is 4% of 2GB, roughly 80MB. That shrinks the error from about 250 times to 10 times, and 80MB is still 8 times over the 10MB broadcast threshold. Purchases are 0.4% of events because event types are far from uniform: page views make up most rows and purchases are rare. An estimate that assumes every value is equally common has no way to see that. Histograms (spark.sql.statistics.histogram.enabled, off by default) would, at the price of a heavier ANALYZE that must be rerun as the data shifts.
What adaptive execution changes, and when
Adaptive query execution, on by default since Spark 3.2, re-plans at each shuffle boundary from the sizes the finished map tasks report. Here, once the events map stage completes, AQE sees around 8MB of shuffle output, under spark.sql.adaptive.autoBroadcastJoinThreshold (which takes the 10MB static value when unset), and rewrites the sort-merge join into a broadcast hash join. Notice what it can't take back. The users map stage was submitted alongside the events stage and has usually already written its 400MB of shuffle files. AQE saves the sort and reads those files through a local shuffle reader instead of fetching them across the network. The exchange by country stays.
| Exchange | Static plan | After the adaptive re-plan |
|---|---|---|
| events by `user_id` | 8MB shuffled, then sorted | 8MB collected and broadcast |
| users by `user_id` | 400MB shuffled, fetched over the network, sorted | 400MB written, read locally, no sort |
| Partial counts by `country` | Shuffled to the final aggregate | Unchanged |
| Stages | 4 | 4, with a cheaper join stage |
The frame, said out loud
- Spark builds a logical plan, optimizes it and generates a physical plan.
- Catalyst applies rules like predicate pushdown.
- AQE then optimizes the plan at runtime.
- The driver commits to a physical plan from estimates before reading data.
- Without column statistics a filter is assumed to keep its whole input, so a selective filter loses its broadcast.
- AQE revises the plan only between stages, from shuffle sizes, never inside a running stage.
The 0.4% share and the 25 event types are this lesson's example. On your own tables, explain(mode="cost") prints the planner's estimates for the same comparison.
Each layer and the thing it cannot see
| Layer | When it decides | Blind spot | Evidence |
|---|---|---|---|
| Rule-based optimizer | Compile time, no data | A Python UDF is opaque: no predicate pushdown through it, no row estimate after it | A `BatchEvalPython` node above a scan whose `PushedFilters` list is empty |
| Cost model | Compile time, stored statistics | Stale statistics, uniform values, independent predicates | `explain(mode="cost")` row counts far from each operator's `number of output rows` |
| Adaptive execution | Between stages, from shuffle sizes | Nothing before the first exchange; no skew fix for aggregations; bytes stand in for work | `AQEShuffleRead` partition count and sizes on the SQL tab |
The cost model's 3 assumptions
Stale statistics
Statistics are a snapshot from the last ANALYZE TABLE. A dimension that has grown from 6MB to 900MB since then still reads as 6MB, so the planner broadcasts it. Spark falls back to file sizes for a table that was never analyzed, but once a size is stored the planner uses it, and automatic size updates (spark.sql.statistics.size.autoUpdate.enabled) are off by default. The direction of the error decides the damage: an overestimate costs a shuffle you didn't need, while an underestimate can broadcast a table that does not fit in memory.
Uniform values
Independent predicates
For country = 'JP' AND language = 'ja', the cost model multiplies the 2 selectivities. If each matches 3% of 50 million users, the estimate is 0.09%, or 45,000 rows. The columns describe nearly the same fact, so the real answer sits close to 3%, about 1.5 million rows: a 33 times underestimate, in the direction that ends in a memory error.
Adaptive execution's own blind spots
- No demotion of a static broadcast. AQE promotes sort-merge joins to broadcast; it has no rule that turns a broadcast the static plan already chose back into a shuffle, so a broadcast picked from stale statistics runs as planned.
- No skew fix for aggregations. Skew handling splits oversized partitions of a sort-merge or shuffled hash join. A groupBy on a hot key still sends that key to a single task. Partial aggregation helps only when the partial result is small, as with a count or sum; collect_list still ships every value.
- Bytes stand in for work. Partition coalescing and the runtime broadcast decision both read serialized, compressed shuffle sizes. Data that compresses 10 to 1, or rows that a later UDF or explode expands, look cheap to AQE and cost far more to process.
- The skew split has a price. Cutting a skewed partition into N pieces means the other side's matching partition is read N times. When both sides are skewed on the same partition, the pieces multiply.
- No new shuffle allowed. If splitting a skewed partition would force an extra exchange later in the plan, AQE skips the fix unless spark.sql.adaptive.forceOptimizeSkewedJoin is set.
The skew rule fires when a partition passes 2 tests at once: larger than 5 times the median partition (spark.sql.adaptive.skewJoin.skewedPartitionFactor) and larger than 256MB (spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes). Take a join whose median reduce partition is 200MB and whose hot partition is 12GB. The rule cuts the hot side into pieces near the 64MB advisory size, up to about 192 of them, bounded by how many map tasks wrote into it. Each piece is joined against the whole matching partition from the other side, so a 300MB partition there is read around 192 times: roughly 57GB of shuffle reads to fix a 12GB hot spot. The trade usually wins because those reads run in parallel across the cluster, and it explains why a join stage's read total can dwarf what was written to it.
How to volunteer one
explain(mode="cost") with the output row counts on the SQL tab.'- Pair each blind spot with the UI metric that would confirm it
- Say which way the estimate errs and what that direction costs
- Name the config that changes the behaviour, with its default
- List every optimizer weakness before the question reaches that layer
- Call a documented design limit a bug
- Claim AQE fixes skew without saying which operators it covers
The tour as usually given. A plan travels along every arrow; only the adaptive step sees runtime sizes, and only once a stage has written its shuffle files.
The same tour, with the failure modes left in
| Phase | What the tour says | What it leaves out |
|---|---|---|
| Analyzer | Resolves tables and columns | Its failures are loud: an unresolved column fails before a single task runs |
| Logical optimizer | Pushes filters down, prunes columns | Stops at a Python UDF, so a filter on a UDF result reads every row group |
| Physical planner | Chooses the join by size | Sizes come from stale statistics or file listings; a selective filter looks as big as its input |
| Adaptive re-plan | Fixes the plan at runtime | Measures compressed bytes, and only after a stage completes |
| Codegen | Fuses operators into one function | Falls back past `spark.sql.codegen.maxFields` (100) or when the generated method grows too large; a Python UDF breaks the fused pipeline |
| Tasks | Run in parallel | A hot key turns the stage's tail serial |
What the follow-ups are looking for
Rewriting a paragraph of the tour
- AQE re-optimizes the plan at runtime using real statistics.
- It coalesces partitions, switches to broadcast joins and handles skew.
- AQE re-plans at shuffle boundaries from the sizes map tasks report.
- Those sizes are compressed bytes, so a stage that expands rows can be coalesced to too few tasks.
- Its skew handling covers joins, so a skewed groupBy still needs a salted key or a two-step aggregate.
A self-check after your recital
Scoring a sample tour
| Check | Result | Why |
|---|---|---|
| A decision made before data | Pass, barely | It names the join choice, but not that the driver fixed it |
| The estimate behind it | Fail | 'Both tables are large' hides that the filtered side was assumed to be 2GB |
| A wrong case with a number | Fail | Nothing says the filter keeps 8MB, so the lost broadcast never comes up |
| The UI view | Fail | No mention of `explain(mode="cost")` or the exchange sizes on the SQL tab |
| Stopped in time | Pass | About 25 seconds spoken |
One added sentence repairs all 3 failing checks: 'The planner assumed the purchase filter kept all 2GB of events, the job measures about 8MB, and I would confirm it by comparing explain(mode="cost") with the output rows on the SQL tab.' The tour keeps its order and its length stays under a minute; it gains the evidence an interviewer would otherwise spend several questions extracting.
A tour with no failure modes still fits a 60-second opener. The miss is offering it as the whole answer when the question asked how the system behaves on a particular query.
The job
A nightly job joins a day of order events to customers, groups by order_id, and runs a Python UDF that parses each order's JSON payload into 40 fields. The team had tuned spark.sql.shuffle.partitions to 400 for this job on a cluster with 200 executor cores, so the UDF stage ran 400 tasks in 2 waves of about 2.5 minutes each, roughly 5 minutes for the stage. Then the platform team moved the cluster to a Spark version with adaptive execution on by default, and set spark.sql.adaptive.coalescePartitions.parallelismFirst to false to cut the count of tiny tasks on the shared cluster. The next night the job missed its deadline.
What the Spark UI showed
On the SQL tab, the exchange before the UDF had an AQEShuffleRead node above it reporting the coalesced partition count and roughly 1.2GB of shuffle data. The page showed the plan as AdaptiveSparkPlan isFinalPlan=true, and the initial plan beside it still read hashpartitioning(order_id, 400). The event timeline showed most executors empty for the length of the stage, and the task summary showed durations clustered tightly: no long tail, every task uniformly slow.
The mechanism
With parallelismFirst off, coalescing packs adjacent shuffle partitions together until each reaches spark.sql.adaptive.advisoryPartitionSizeInBytes, 64MB by default. The job wrote roughly 1.2GB of shuffle output, and dividing that by 64MB gives roughly 19 partitions. The tuned 400 had become only the starting count. The flaw is in what the 64MB measures: compressed shuffle bytes, the input to the stage. The expensive work in this stage was the UDF, whose cost follows row count and payload length, and repetitive JSON compresses very well. Each remaining task now carried about 21 times the rows of an old one, so a 2.5 minute task became a 53 minute task, while 181 of the 200 cores waited.
Fixes, and what each one costs
| Fix | Effect on the UDF stage | Cost |
|---|---|---|
| Set `spark.sql.adaptive.coalescePartitions.enabled` to false for this job | Back to 400 tasks, about 5 minutes | Every other shuffle in the job loses coalescing, and its cheap stages return to 400 tiny tasks |
| Lower the advisory size to 8MB for this job | About 154 tasks in a single wave, roughly 6.5 minutes | Every shuffle in the job is sized finer, including the cheap ones |
| `repartition(400)` just before the UDF | 400 tasks, since AQE leaves an explicit partition count alone | An extra shuffle of the aggregated rows and a further stage |
The team chose the repartition(400). It confined the fix to the single expensive step and kept coalescing for the cheap shuffles elsewhere in the job. The extra stage wrote less than the 1.2GB the aggregation had shuffled, a small price next to a 48 minute regression. They rejected turning coalescing off, because that fixes this stage and silently regresses every small shuffle in the job.
Other backfires with the same shape
| Backfire | Mechanism | What you would see |
|---|---|---|
| Runtime broadcast exhausts memory | The conversion reads serialized shuffle size; the hash table built from those rows in memory can be several times larger | Driver or executor memory errors around a broadcast exchange that the initial plan did not have |
| Skew split multiplies reads | A skewed partition cut into N pieces re-reads the other side's matching partition N times | The join stage's task count jumps and its shuffle read exceeds the shuffle written into it |
Telling it
> A food delivery marketplace runs an hourly Spark job that joins order events to restaurants, keeps orders flagged for refund review (under 1% of rows), aggregates refunds per restaurant, and calls a fraud-scoring Python UDF on each restaurant's order history. After a cluster upgrade, the job runs twice as long in some hours and faster in others.
Spark picks its plan before reading data. Name where that bet loses, and tell the time the fix lost too.
- Category
- Spark
- Difficulty
- advanced
- Duration
- 16 minutes
- Challenges
- 3 hands-on challenges
Topics covered: Where the Model Breaks, Frame the Static-Plan Limit, Volunteer Each Blind Spot, A Tour With No Failure Modes, A Time AQE Made It Worse
Lesson Sections
- Where the Model Breaks
At this depth the interviewer takes for granted that you can trace a query from the driver to the executors. What they want to know is whether you understand that the trace describes decisions made once, on the driver, before any input has been read. Every choice in the physical plan, from the join strategy to the reducer count to which side becomes the hash table, is a bet placed on estimates. A recital scores at the points where those bets lose. What the planner does read 3 facts the planner c
- Frame the Static-Plan Limit
The opening sentence of a strong recital names the limit before the interviewer has to dig for it: Catalyst produces a single physical plan from what it can estimate at compile time, and adaptive execution can revise that plan only at shuffle boundaries, using sizes measured after a stage finishes. Every later claim in the answer hangs off that frame, so it pays to rehearse it on a concrete query. The query on the table Statistics move the guess, and the guess stays a guess What adaptive executi
- Volunteer Each Blind Spot (concepts: Catalyst Optimizer)
An interviewer probing a recital is searching for the edge of what you know, asking 'and when does that go wrong?' after each layer until the answers run out. Volunteering each layer's blind spot before that question arrives changes the conversation. It shows you have watched the layer fail on real data, and it moves the interviewer from testing recall to discussing a decision, which is the conversation they came to have. Each layer and the thing it cannot see The cost model's 3 assumptions Stal
- A Tour With No Failure Modes
The most common answer to 'walk me through Spark's execution' at this depth is a polished tour: every phase named in order, every component described accurately, and no case where any of it produces a bad plan. Interviewers hear it often enough that it signals preparation more than experience. A correct tour scores as the baseline, and the follow-up questions are built to get past it. The same tour, with the failure modes left in What the follow-ups are looking for After a tour, the interviewer'
- A Time AQE Made It Worse (concepts: Adaptive Query Execution, Configuration Tuning)
Interviewers ask for a backfire story because a story is hard to fake: the details either connect or they do not. A credible one has 4 parts in order: what changed, what the Spark UI showed, the mechanism that explains it, and the fix together with what the fix cost. Here is one built on a mechanism you can reproduce, with numbers that hold together. The job What the Spark UI showed The mechanism Fixes, and what each one costs Other backfires with the same shape Telling it