◐ ai-teacher

🔥 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):

No shuffle:

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 itself

Three 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

Words to know

ShuffleRedistributing rows across partitions by key: map-side write to local disk, reduce-side fetch over the network.
ExchangeThe physical-plan operator that represents a shuffle; its partitioning shows why it exists.
hashpartitioningExchange that routes rows by hash(key) % n — used by groupBy, joins and partitioned windows.
rangepartitioningExchange that routes rows into sorted key ranges chosen by sampling — used by orderBy.
Map-side combinePartial aggregation before the Exchange so each task sends one row per key instead of every row.
SpillData that did not fit in execution memory during shuffle/sort and was written to disk.
coalesceReduce partition count by merging existing partitions locally, without a shuffle.

In two minutes

Test yourself

1. Which operation does NOT add an Exchange to the plan?

  1. dropDuplicates(['ride_id'])
  2. union
  3. orderBy('fare_inr')
  4. 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?

  1. A groupBy
  2. A broadcast join
  3. A filter
  4. 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?

  1. Combining rows per key inside each task so far fewer rows cross the network
  2. Checking the sum is correct
  3. Sorting the data
  4. 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?

  1. Increase shuffle partitions to 2000
  2. Cache the rides table
  3. Broadcast the drivers table
  4. 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?

  1. coalesce(20) — no shuffle, but the preceding narrow work in that stage also runs on only 20 tasks
  2. coalesce(20) — identical to repartition
  3. orderBy then write — sorted data compresses better
  4. 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?

  1. Add a broadcast hint to the groupBy
  2. Raise spark.sql.shuffle.partitions so each partition is ~100–200MB
  3. Turn off AQE
  4. 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.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →