🔥 Spark & Databricks · Chapter 4
Shuffles — the thing that makes jobs slow
Every Exchange in your plan is a full copy of the data across the network. Count them before you run anything.
Study this lesson free in the app →
The basics
A shuffle is Spark redistributing rows across the cluster so that all rows sharing a key end up in the same partition.
Why it exists: to sum fares per city, every Shillong ride must be in one place. But the rides were loaded in whatever order the files came — Shillong rides are spread across all 320 partitions. Spark has to move them. That movement is the shuffle: each task writes its rows into buckets by key on local disk, and the next stage's tasks fetch their bucket from every executor over the network.
Mental model: a post-office sorting room. Mail arrives in sacks from every town, mixed. Before delivery, every letter is sorted into the bin for its destination — every sack touches every bin. That sorting step is expensive no matter how fast the postmen walk.
Words you must know 5
- Wide transformation
- needs rows from other partitions (groupBy, join, distinct, orderBy, repartition)
- Exchange
- the node in a physical plan that represents a shuffle
- Shuffle write / read
- bytes written by the map side, fetched by the reduce side
- Spill
- shuffle or sort data that did not fit in memory and went to disk
- Broadcast
- sending a small table to every executor instead of shuffling the big one
Explained
Meghalaya Cabs' driver-earnings job reads 400GB, groups by driver, joins the driver table, and sorts by earnings.
explain() shows three Exchange nodes. That is three times the job writes data to disk, sends it across the network and reads it back.
Rewritten — broadcast the driver table, drop the sort nobody reads — the plan has one. Same output. A third of the runtime.
The post-office sorting room
Fifty sacks arrive, one from each town, each holding letters for every district. To deliver, the office must get all Mawlai letters into one bin.
Every sack is opened, every letter thrown into one of 200 bins, and each postman collects his bin from every sorting table. Every sack touches every bin. That is the shuffle.
Three ways to make it cheaper: throw away junk mail before sorting (filter first), bundle letters to the same house with a rubber band (pre-aggregate), or — if one district's list is tiny — photocopy it for every postman instead of sorting anything (broadcast).
What one shuffle actually does
Serialise, write, network, read, deserialise — for every row that crosses. That is why one shuffle can outweigh all the narrow work in a job.
What triggers one — and what doesn't
Shuffle (Exchange):
groupBy().agg(),distinct(),dropDuplicates()→ hashpartitioning on the keysjoin()(sort-merge or shuffle-hash) → hashpartitioning on the join keys, both sidesorderBy()/sort()→ rangepartitioning (Spark samples the data first to pick ranges)repartition(n)→ round-robin;repartition('col')→ hashpartitioning- Window with
partitionBy→ hashpartitioning on the window keys intersect,except
No shuffle:
filter,select,withColumn,unioncoalesce(n)— merges neighbouring partitions locally- A join where one side is broadcast — only the small side moves
sortWithinPartitions— sorts each partition in place
And one optimisation: if a child is already hash-partitioned on the needed key with the right partition count, Spark's planner (EnsureRequirements) inserts no Exchange.
Reading Exchange in a plan
rev = (rides.filter(col('status') == 'COMPLETED')
.groupBy('city').agg(sum('fare_inr')))
rev.explain()AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[city], functions=[sum(fare_inr)]) ← final
+- Exchange hashpartitioning(city, 200), ENSURE_REQUIREMENTS ← SHUFFLE
+- HashAggregate(keys=[city], functions=[partial_sum(fare_inr)])
+- Project [city, fare_inr]
+- Filter (isnotnull(status) AND (status = COMPLETED))
+- FileScan parquet [status,fare_inr,city]Read it bottom-up. The partial_sum before the Exchange is the map-side combine: each task sends one row per city, not one row per ride — 400M rows shrink to a few thousand before crossing the network.
A sort-merge join shows Exchange hashpartitioning(driver_id, 200) + Sort under both sides. A broadcast join shows BroadcastExchange on the small side and nothing above the big side's scan.
Deleting Exchanges
from pyspark.sql.functions import broadcast
# BEFORE: 3 shuffles
out = (rides.join(drivers, 'driver_id') # SMJ: 2 Exchanges
.groupBy('driver_id', 'name') # 0: reuses the join's driver_id partitioning
.agg(sum('fare_inr').alias('earned'))
.orderBy(desc('earned'))) # 1 Exchange (range)
# AFTER: 1 shuffle
earned = (rides.select('driver_id', 'fare_inr') # prune columns first
.groupBy('driver_id') # 1 Exchange, tiny after partial agg
.agg(sum('fare_inr').alias('earned')))
out = earned.join(broadcast(drivers.select('driver_id', 'name')), 'driver_id')
# no orderBy: the dashboard sorts 40k rows itselfThree moves: aggregate before joining (shuffle 40k partial rows, not 400M full ones), broadcast the 30MB driver dimension (two Exchanges gone), drop a sort nobody needed (one more gone). The one shuffle left is tiny.
At 100×
Shuffle partition size is the dial. Default spark.sql.shuffle.partitions = 200. A 2TB shuffle into 200 partitions is 10GB per task — guaranteed spill. Target roughly 100–200MB per shuffle partition; AQE (on by default since Spark 3.2) coalesces small ones, but it cannot split upward if you started with too few, so set the initial number high (e.g. 2,000+) for big jobs.
Shuffle disk is local disk. Executors on nodes with small or slow local disks fail with No space left on device mid-shuffle.
Fetch failures (FetchFailedException) mean a reducer could not get a block — usually the executor holding it died. Spark reruns the map stage that produced it, which can turn one lost node into a 30-minute retry.
Skew is a shuffle problem: hashing puts every row of one hot key in one partition. That is its own topic (sp-006).
Gotchas
repartition(10)before writing to shrink files — a full shuffle whencoalesce(10)would merge locally (but beware: coalesce also reduces the parallelism of the work before it in the same stage)orderBybefore a write nobody reads in order — a range shuffle for nothingdistinct()on all columns to dedupe — shuffles every column; dedupe on the key withdropDuplicates(['ride_id'])after selecting what you need- Joining before aggregating — moves 400M rows when 40k would do
- A UDF or cast on the join key — prevents Spark from recognising existing partitioning, adding an Exchange
- Reading the plan top-down — data flows from the bottom (scan) up
Words to know
| Shuffle | Redistributing rows across partitions by key: map-side write to local disk, reduce-side fetch over the network. |
|---|---|
| Exchange | The physical-plan operator that represents a shuffle; its partitioning shows why it exists. |
| hashpartitioning | Exchange that routes rows by hash(key) % n — used by groupBy, joins and partitioned windows. |
| rangepartitioning | Exchange that routes rows into sorted key ranges chosen by sampling — used by orderBy. |
| Map-side combine | Partial aggregation before the Exchange so each task sends one row per key instead of every row. |
| Spill | Data that did not fit in execution memory during shuffle/sort and was written to disk. |
| coalesce | Reduce partition count by merging existing partitions locally, without a shuffle. |
In two minutes
- A shuffle repartitions data by key: map tasks write bucketed files to local disk, reduce tasks fetch their bucket from every executor.
- Triggers: groupBy/agg, distinct/dropDuplicates, most joins, orderBy, repartition, window functions with partitionBy, intersect/except.
- Not triggers: filter, select, withColumn, union, coalesce, and joins where one side is broadcast.
- In explain(), every shuffle is an
Exchange— hashpartitioning (groupBy/join), rangepartitioning (orderBy), RoundRobinPartitioning (repartition(n)). BroadcastExchange is not a shuffle of the big side. - Spark skips an Exchange when the child is already partitioned the way the next operator needs — so aggregating then joining on the same key shuffles once per side, not twice.
- Avoid shuffles by broadcasting small tables, filtering and pruning columns first, aggregating before joining, reusing existing partitioning, and using coalesce instead of repartition to shrink.
- In the UI, Shuffle Write, Shuffle Read and Spill (memory/disk) tell you how big each shuffle was and whether it fit in memory.
Test yourself
1. Which operation does NOT add an Exchange to the plan?
- dropDuplicates(['ride_id'])
- union
- orderBy('fare_inr')
- repartition(50)
Show the answer
union — union just concatenates partition lists. dropDuplicates hash-shuffles, orderBy range-shuffles, repartition round-robin shuffles.
2. In explain() you see Exchange rangepartitioning(earned DESC, 200). Which operation produced it?
- A groupBy
- A broadcast join
- A filter
- An orderBy / sort
Show the answer
An orderBy / sort — Global sorts use range partitioning: Spark samples to choose 200 ranges so partition i holds keys smaller than partition i+1. groupBy uses hashpartitioning.
3. What is the partial_sum HashAggregate below the Exchange for?
- Combining rows per key inside each task so far fewer rows cross the network
- Checking the sum is correct
- Sorting the data
- Writing the final result
Show the answer
Combining rows per key inside each task so far fewer rows cross the network — Map-side combine: each task sends one partial sum per city instead of every ride. The final HashAggregate above the Exchange merges the partials.
4. A job joins 400M rides to a 30MB drivers table and the plan shows two Exchanges and two Sorts under a SortMergeJoin. Cheapest fix?
- Increase shuffle partitions to 2000
- Cache the rides table
- Broadcast the drivers table
- repartition both tables by driver_id first
Show the answer
Broadcast the drivers table — 30MB is above the 10MB default auto-broadcast threshold, so Spark chose SMJ. broadcast(drivers) removes both Exchanges and sorts; the big side is never shuffled. Repartitioning first just moves the same shuffles earlier.
5. To write 2,000 small output partitions as 20 files, you use repartition(20). What's the cheaper alternative and its catch?
- coalesce(20) — no shuffle, but the preceding narrow work in that stage also runs on only 20 tasks
- coalesce(20) — identical to repartition
- orderBy then write — sorted data compresses better
- There is no alternative
Show the answer
coalesce(20) — no shuffle, but the preceding narrow work in that stage also runs on only 20 tasks — coalesce merges partitions locally, avoiding a shuffle, but because it is narrow it shrinks the parallelism of the whole stage it is in. If heavy work precedes it, repartition may actually be faster.
6. Stage metrics show Shuffle Read 1.8TB across 200 tasks and Spill (Disk) 2.4TB. What's the first change?
- Add a broadcast hint to the groupBy
- Raise spark.sql.shuffle.partitions so each partition is ~100–200MB
- Turn off AQE
- Call cache() before the groupBy
Show the answer
Raise spark.sql.shuffle.partitions so each partition is ~100–200MB — 1.8TB / 200 ≈ 9GB per task — far beyond execution memory, so it spills. More shuffle partitions make each fit. Broadcast hints apply to joins, not aggregations.
There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →