Runtime optimization with AQE (coalesce partitions, skew joins, join switching) and cost-based optimization from table statistics.
⏱ 11 min readTopics chapter readerLevel · Performance
01 · Orientation
Start here
⏱ 5 min · Topic 1 of 12
Catalyst plans a query before it runs. It guesses sizes, and a guess can be wrong. Adaptive Query Execution, or AQE, fixes the plan while the query runs.
You met AQE in earlier chapters, for example "Joins in Spark" and "Data Skew & Salting". This chapter shows how it works, and how to read what it did.
You will learn five things.
How AQE uses real sizes at the border between two stages.
Three features: merge small partitions, switch the join, split skewed partitions.
What AQE cannot fix.
How to read isFinalPlan=true and the Initial Plan.
How the cost-based optimizer (CBO) and AQE work together, and which setting fits which symptom.
We use the shop again. The paid revenue by customer is Asha 65.00, Carla 15.50 and Dev 80.00.
02 · The idea
How AQE works
⏱ 6 min · Topic 2 of 12
A shuffle moves rows between machines, so each key lands in one place. You read about it in "The Shuffle: Spark's Performance Killer". A shuffle splits a query into stages.
A stage is a group of tasks that run without a shuffle.
When a stage ends, Spark has written its shuffle files. At that moment it knows the real size of every partition. AQE uses these facts.
Spark runs a query with AQE in four steps.
Spark cuts the plan at each shuffle. Each piece is a query stage.
Spark runs every stage that does not wait for another stage.
When a stage finishes, Spark reads its real sizes, in bytes and rows.
Spark re-optimizes the rest of the plan with those sizes. Then it runs the next stages.
Catalyst has only estimates when it plans. AQE has measurements, but only after a shuffle has run. So AQE can change the rest of the plan, not the part that already ran.
AQE is on by default since Spark 3.2. Its main setting is spark.sql.adaptive.enabled. Check your version if you are not sure.
03 · What AQE does
Three AQE features
⏱ 4 min · Topic 3 of 12
AQE has many parts. Three matter most. Each one has a setting and a default.
Feature
Setting to check
Default
Merge small partitions after a shuffle
spark.sql.adaptive.coalescePartitions.enabled
true
Change a sort-merge join to a broadcast join
spark.sql.adaptive.autoBroadcastJoinThreshold
Not set. Spark uses spark.sql.autoBroadcastJoinThreshold, 10 MB.
Split a skewed partition in a join
spark.sql.adaptive.skewJoin.enabled
true
The next three sections show one feature each. Each one shows a simplified plan from explain().
Settings and defaults come from Spark 3.2 and later. Names change a little between versions, so check yours.
04 · Feature 1
Merge small partitions
⏱ 7 min · Topic 4 of 12
After a shuffle, Spark makes spark.sql.shuffle.partitions partitions. The default is 200. On small data, most of them are empty or tiny.
AQE merges small partitions that sit next to each other, for example partitions 0, 1 and 2. This is called coalescing.
It merges up to a target size. The target is at most spark.sql.adaptive.advisoryPartitionSizeInBytes, 64 MB by default.
But spark.sql.adaptive.coalescePartitions.parallelismFirst is true by default. Then Spark aims for about one partition per core, with a target size of at least 1 MB.
Look at paid revenue by customer. Only customers 1, 3 and 4 have paid orders.
Paid revenue by customer: plan before and after (simplified)worked example
Three customers means at most 3 of the 200 partitions hold rows. At least 197 are empty.
The line AQEShuffleRead coalesced shows that AQE merged partitions. On this tiny data, AQE probably reads everything as one partition, so one task does the final step.
The Exchange line still says 200. That is the plan Spark made before the run. AQEShuffleRead sits above it and merges the small pieces when it reads them.
Merging also means fewer small output files. A groupBy followed by a write makes fewer, bigger files.
Watch out
Coalescing only merges partitions. It never makes more. If partitions are too big, lower the advisory size or raise spark.sql.shuffle.partitions.
05 · Feature 2
Switch the join type
⏱ 8 min · Topic 5 of 12
A sort-merge join shuffles both tables. A broadcast join copies the small table to every executor.
In "Joins in Spark", you saw AQE switch this join after a filter. Here you read the plan that shows the switch.
Spark picks the join before the run, from size estimates. After a filter, the real size can be much smaller than the estimate. AQE sees the real size after the shuffle and can switch to a broadcast join.
Join orders to the customers from India (country = IN).
Orders joined to customers in India: plan before and after (simplified)worked example
The customers in India are Asha (1) and Omar (5). Omar has no orders, so only Asha’s orders 101 and 102 match.
On a real customers table, Spark cannot know how many rows are in India before it runs the filter. The shop tables are tiny, so this switch is only a picture.
The join line changed from SortMergeJoin to BroadcastHashJoin. The Sort lines are gone.
BuildRight means the right side, customers, is the copied table.
Both Exchange lines are still there. Both shuffles already ran when AQE saw the sizes. So the saving is partial: Spark skips the sorts and the network fetch of the big side.
AQEShuffleRead local is the local shuffle reader. It reads shuffle files on the same machine, so rows do not cross the network. It is on by default (spark.sql.adaptive.localShuffleReader.enabled).
Remember
F.broadcast(), added before the run, skips the big shuffle. AQE switches only after the shuffle has already happened.
06 · Feature 3
Split skewed partitions
⏱ 6 min · Topic 6 of 12
You met skew in "Data Skew & Salting". One partition is much bigger than the others.
AQE splits it when it is over 5 times the median and over 256 MB. The median is the middle partition size.
You see it in the final plan. The join line gets a marker, and the read line says skewed.
Final plan of a skewed join (simplified)worked example
The plan has no Exchange line and no AdaptiveSparkPlan line. Spark has no shuffle to measure.
Spark wraps a plan in AdaptiveSparkPlan when it has an Exchange, a join, a groupBy or a subquery. This one has none of them.
Watch out
Do not expect AQE to speed up a job that only reads, filters and writes. Fix the scan instead: read fewer columns and filter early.
08 · Reading plans
Read the final plan
⏱ 6 min · Topic 8 of 12
The plan you print is not always the plan that ran. Before the run, explain() shows isFinalPlan=false. That plan can still change.
After df.collect(), call df.explain() again. Now it shows isFinalPlan=true. The plan has two parts, as the earlier example showed.
Use collect() on the same DataFrame. count() and show() run a new plan, so df.explain() still shows isFinalPlan=false.
Part of the printout
What it is
isFinalPlan=false
AQE has not finished. This is the plan before the run.
isFinalPlan=true
The query ran. The Final Plan is what really ran.
Final Plan
The tree with query stages and AQEShuffleRead lines.
Initial Plan
The plan before AQE changed it. Compare it with the Final Plan.
Compare the two parts line by line. A new BroadcastHashJoin, a coalesced read or skew=true tells you what AQE did.
Match the scan lines first. Then match the Exchange lines.
The Spark UI shows the same in the SQL tab. Open the query.
The graph shows the final plan when the query ends. While it runs, the graph can change.
09 · Statistics
CBO and AQE together
⏱ 8 min · Topic 9 of 12
You met the cost-based optimizer (CBO) in "Spark SQL & the Catalyst Optimizer". Statistics are facts about a table, such as the row count. CBO needs them from ANALYZE TABLE, and spark.sql.cbo.enabled is false by default.
The two work at different times.
CBO
AQE
When
Before the run
During the run
It uses
Estimates from saved statistics
Measurements from finished shuffles
Default
Off
On since Spark 3.2
Best at
Join order across many tables
Partition size, join type, skew
Weak when
Statistics are old or missing
There is no shuffle yet
CBO still matters for a query with many joins. AQE does not reorder joins. Only CBO picks the order by cost, before the run.
To use it, collect statistics and turn on two settings.
Join reordering needs three or more joined tables. Run ANALYZE on every table in the join. Run it again after big loads.
Dynamic partition pruning is a third helper. Here, a partition means one folder of a table, not a shuffle partition.
Some tables are stored in folders, one folder for each value of a column. If the big table uses the join key for its folders, Spark can skip folders. It reads only the folders that the filtered small table needs.
By default, this works only when Spark copies the small side in a broadcast join. The setting is spark.sql.optimizer.dynamicPartitionPruning.enabled, true by default.
Interview question
The question: "What is the difference between CBO and AQE?" A good answer: "CBO plans from saved statistics before the run. AQE re-plans from real shuffle sizes during the run."
10 · Decide
Which setting to touch
⏱ 4 min · Topic 10 of 12
Start from the symptom, not from the setting. Find your symptom in the left column.
Symptom
Setting to check
What to do
No AdaptiveSparkPlan line, and the plan has a shuffle
spark.sql.adaptive.enabled
Make sure it is true. An old config may turn it off.
Many tiny tasks or many small output files
spark.sql.adaptive.coalescePartitions.enabled
Make sure it is true. Then set spark.sql.adaptive.coalescePartitions.parallelismFirst to false. Now Spark uses the advisory size.
Tasks after a shuffle are too big or spill
spark.sql.adaptive.advisoryPartitionSizeInBytes
Lower it. If that fails, raise spark.sql.shuffle.partitions.
A small side stays in a sort-merge join
spark.sql.adaptive.autoBroadcastJoinThreshold
Raise it only if the small side fits in driver and executor memory.
One slow task in a join
spark.sql.adaptive.skewJoin.skewedPartitionFactor
Read "Data Skew & Salting" before you lower any limit.
Join order is wrong across many tables
spark.sql.cbo.joinReorder.enabled
Run ANALYZE TABLE, then turn on both CBO settings.
Change one setting at a time. Then compare the Final Plan with the Initial Plan.
11 · Recap
Check yourself
⏱ 4 min · Topic 11 of 12
Four points to keep.
AQE reads real sizes at each shuffle and re-optimizes the rest of the plan.
It merges small partitions, switches to a broadcast join, and splits skewed partitions.
It cannot fix a bad first plan, a plan with no shuffle, or a slow Python UDF.
To see the Final Plan, call explain() again on the same DataFrame after collect().
Answer these without looking back.
Why does a plan with no Exchange have no AdaptiveSparkPlan line?
In the join example, why is the saving only partial after the switch?
Which line in the Final Plan shows that partitions were merged?
Which optimizer can reorder a join of five tables, and what does it need first?
12 · Next Chapter
Next Chapter
You can now read an adaptive plan and say what AQE and the CBO did.
⏱ 3 min · Topic 12 of 12
Next chapter
Performance Tuning & Debugging with the Spark UI
You can now read an adaptive plan and say what AQE and the CBO did.
Next you learn a repeatable way to read the Spark UI, find the slow stage and choose a fix.