Shuffles: Intermediate
The rest of this lesson follows that job over a year of plays read as 400 partitions on a cluster of 20 executors. Stage 0 runs 400 map tasks. Each reads its split, applies the filter in the same pass, and for every surviving row works out which of the 200 reduce partitions owns its (listener_id, artist_id) pair. The 200 is the default of spark.sql.shuffle.partitions, the setting that fixes how many partitions a DataFrame shuffle produces.
Choosing the target partition
For a groupBy, the owner comes from a hash of the grouping columns, taken modulo the number of reduce partitions. Every map task applies the same function, so a given listener and artist pair goes to the same reducer whichever task happened to read it. That agreement lets reducer 17 collect every row for its keys without asking anyone where they went. A later section of this lesson opens up the hash and the other rule Spark uses.
What lands on disk
| Quantity | Rule | Running example |
|---|---|---|
| Map tasks | 1 per input partition | 400 |
| Reduce partitions | `spark.sql.shuffle.partitions` | 200 |
| Files on local disk | 2 per map task | 800 |
| Shuffle blocks | map tasks times reduce partitions | 80,000 |
| Blocks per map task | 1 per reduce partition | 200 |
Which disk, and for how long
The files go under spark.local.dir, which is /tmp unless set, on the machine running the executor; cluster managers often substitute directories of their own. A job writing 500GB of shuffle across 20 executors therefore needs about 25GB of free local disk on each, and when one machine fills up, its map tasks fail with No space left on device while the cluster's main storage sits half empty. The files also outlive the tasks that wrote them, because the next stage still has to fetch them.
orders.groupBy("region").count().collect()
The write in the Spark UI
A stage of 300 map tasks feeds a groupBy with spark.sql.shuffle.partitions set to 100. What does its shuffle write leave on the executors' local disks?
Stage 0 leaves 800 files on the executors' local disks; each of the 200 reduce tasks in stage 1 fetches its block from all 400 data files.
From blocks to results
Fetches go out in parallel batches, so a reducer pulls from several executors at once while it processes blocks that have already arrived. For the groupBy, processing means merging: the reducer feeds each incoming row into a hash map keyed by (listener_id, artist_id), adding counts for rows that share a key. Once the last block is merged, every key's count is final, because no other reducer holds rows for those keys. The finished rows then flow into the narrow work after the shuffle in the same stage, here the parquet write.
The read mirrors the write
Each byte stage 0 writes, stage 1 reads, so the reduce stage's Shuffle Read total matches the map stage's Shuffle Write total. The split differs. Writes were divided by input partition, and reads are divided by key, each reducer taking whatever share of the data its keys own. In this job that share stays close across reducers. As the next section shows, each map task sends no more than 1 row for each (listener_id, artist_id) pair, so a listener who streams all day adds at most 400 rows per artist, and the hash spreads their artists over many reducers. Read sizes split apart when raw rows cross the shuffle, as after repartition("listener_id"): every play of a heavy listener lands on 1 reducer, that reducer reads far more than the median, and the stage waits on it.
Read metrics in the Spark UI
| Task metric | What it counts | What a high value suggests |
|---|---|---|
| Shuffle Read Size / Records | Bytes and rows the task fetched | Far above the median: its keys own a large share of rows |
| Shuffle Remote Reads | Bytes fetched from other executors | Near the total: expected when executors are many |
| Shuffle Read Blocked Time | Time the task waited with no block to process | A large share of task time: fetching is the bottleneck |
A reduce stage of 200 tasks is slow. Read sizes per task are within 10% of each other, the median task takes 40 seconds, and the median Shuffle Read Blocked Time is 31 seconds. Where should you look first?
Reducers with nothing to read
The plan sets the reduce task count to spark.sql.shuffle.partitions, whatever the data holds. With adaptive execution turned off (spark.sql.adaptive.enabled set to false), a groupBy on a column with 3 distinct values launches all 200 reduce tasks. Each key's rows land in a single partition, so at least 197 tasks find every block empty and finish with no output, more if 2 of the keys hash to the same partition. Each empty task still costs the driver a scheduling round trip. With adaptive execution on, the default since Spark 3.2, Spark measures the map output once the map stage ends and merges the small and empty partitions into a few tasks, often 1 for 3 keys. The Adaptive Query Execution lessons cover how.
spark.conf.set("spark.sql.shuffle.partitions", 6)(orders.groupBy("region").agg(F.sum("amount").alias("revenue")).collect())
events is read as 120 partitions, and its country column holds 8 distinct values. If spark.sql.shuffle.partitions is 50, how many shuffle blocks belong to each reduce task of events.groupBy("country").count(), and how many reduce tasks can produce rows?
The cheapest shuffle byte is the one never written. For count, sum, min, max, avg and most other built-in aggregates, Spark computes the result in 2 passes. A partial pass runs inside each map task before the shuffle write and collapses that task's rows to 1 row per key it saw, carrying a running result. A final pass runs in the reducer after the read and merges each key's partial rows. Every DataFrame groupBy using these aggregates gets the partial pass without being asked.
| region |
|---|
| US |
| EU |
| US |
| US |
| EU |
| US |
| region | partial_count |
|---|---|
| US | 4 |
| EU | 2 |
Both outputs give the reducer what it needs: it adds US 4 to the partial US counts from every other map task and reaches the same total. The partial version writes 2 rows where the raw version writes 6. For avg, the partial row carries a sum and a count, since an average of averages would be wrong.
What decides the saving
| Grouping key | Distinct keys | Rows per map task, at most | Rows shuffled, at most |
|---|---|---|---|
| `country` | 180 | 180 | 72,000 |
| `artist_id` | 50,000 | 50,000 | 20,000,000 |
| `listener_id` | 100,000,000 | 2,500,000 | 1,000,000,000 |
The same groupBy code cuts the shuffle by a factor of about 14,000 for country, 50 for artist, and almost nothing for listener: a task holding 2.5 million rows in arrival order sees nearly every listener once. Layout matters as much as the key. Had the files been written so each partition held a contiguous range of listeners, each task would see all of a listener's plays together, around 10 on average, and the partial pass would cut the shuffle about 10 times.
Two copies of the 1 billion plays each sit in 400 partitions. Copy A is in arrival order. Copy B was written so each partition holds a contiguous range of listener_id values. Which is true of groupBy("listener_id").count()?
Aggregates that combine poorly
- count, sum, min, max and avg: a fixed few numbers per key, however many rows fed it.
- collect_list and collect_set: the values themselves, so rows collapse while the bytes stay close to the raw values.
- countDistinct: only repeated (key, value) pairs can be dropped, so Spark shuffles twice, once on key and value, then once on key.
- approx_count_distinct: a fixed-size sketch, so the job shuffles once, with a default relative standard deviation of 5%.
Run as written, exact takes 2 shuffles and 3 stages: the first shuffle groups by (artist_id, listener_id) so duplicate pairs meet and drop, and the second groups the surviving pairs by artist_id to count them. approx takes 1 shuffle and 2 stages, and each map task writes 1 sketch per artist it saw, the same size whatever the listener count.
Check the ratio on real data
The cell below counts rows and distinct values for 3 columns of the practice transactions table. Run it, then divide rows by each key count. The quotient is the most a partial pass could shrink a groupBy on that column if 1 task saw every row; how the rows are split across tasks decides how close a real job gets.
from pyspark.sql import functions as F
tx = spark.table("transactions")
tx.agg(
F.count("*").alias("rows"),
F.countDistinct("product_id").alias("product_keys"),
F.countDistinct("user_id").alias("user_keys"),
F.countDistinct("transaction_id").alias("transaction_keys"),
).show()Every map task has to send each row to exactly 1 reducer, using a rule every other map task also applies. For shuffles keyed on columns, Spark has 2 rules. Hash partitioning sends a row to partition pmod(hash(key), n), which hashes the key columns with Murmur3, divides the hash by the partition count n, and keeps the non-negative remainder. Range partitioning cuts the key's sorted values into n contiguous intervals and sends each row to the interval its key falls in. A third rule, round-robin, deals rows out in turn with no key at all; repartition(n) uses it.
- Used by groupBy, distinct, joins and repartition(col)
- Same key, same partition, for a fixed n
- Neighbouring keys scatter across partitions
- Needs no pass over the data before the shuffle
- Sizes even out when keys are many and similar in size
- Used by orderBy, sort and repartitionByRange(col)
- Same key, same partition, within 1 run
- Every key in partition i sorts before every key in partition i+1
- Runs a sampling job first to pick boundaries
- Sizes depend on how well the sample matches the data
Why a sort needs ranges
A groupBy reducer only needs every row for its keys, and a hash delivers that. A global sort needs more. Each reducer sorts its own rows, and reading partition 0, then 1, then 2 has to yield 1 ordered result, which holds only if every key in partition 0 sorts before every key in partition 1. A hash sends adjacent values like 99 and 100 to unrelated partitions, so orderBy uses ranges.
To place the boundaries, Spark first samples the data: around 100 keys per output partition by default, the value of spark.sql.execution.rangeExchange.sampleSizePerPartition, drawn from every input partition. It sorts the sample and picks n minus 1 cut points that split it evenly. That sampling is a job of its own, the kind you saw run ahead of an action's main job, and it computes the input once for the sample and again for the shuffle.
| amount | Range partition | Why |
|---|---|---|
| 30 | 0 | 50 or less |
| 45 | 0 | 50 or less |
| 60 | 1 | above 50, 100 or less |
| 95 | 1 | above 50, 100 or less |
| 120 | 2 | above 100 |
The animation sorts 9 orders by amount into 3 reduce partitions, with cut points drawn from its own sample. Watch which amounts reach each reducer: low ones at the first, high ones at the last, and each reducer sorts only its own share. Collected in partition order, the result runs 15, 30, 45, 60, 70, 80, 95, 120, 140.
orders.orderBy("amount").collect()
Which rule your code gets
| Code | Partitioning | Shuffles |
|---|---|---|
| `df.groupBy("region").count()` | hash on region | 1 |
| `df.distinct()` | hash on every column | 1 |
| `df.orderBy("amount")` | range on amount | 1 |
| `df.repartition(50, "region")` | hash on region, 50 partitions | 1 |
| `df.repartitionByRange(50, "amount")` | range on amount, 50 partitions | 1 |
| `df.repartition(50)` | round-robin, 50 partitions | 1 |
| `df.groupBy("region").count().orderBy("count")` | hash on region, then range on count | 2 |
The partition count is part of the hash
Because the rule ends in a remainder, the same key lands in unrelated partitions under different counts: US might sit in partition 41 of 200 and partition 7 of 50. Two outputs hash-partitioned on the same column line up partition by partition only when they used the same n. Range layouts line up even less often, since their boundaries come from a sample of 1 run's data.
Neither rule splits a key: every row with country = 'US' lands in 1 partition under hash and under range alike.
A job runs events.repartitionByRange(4, "event_date") over a year of events. Which is true of the 4 output partitions?
Partitioning already in place
A groupBy("region") needs every row of a region in 1 partition. After repartition("region") that already holds: the repartition hashed every row by region. Before adding a shuffle, Spark compares what each operation needs with how its input is partitioned, and here they match, so the aggregation runs inside the repartition's reduce tasks with no second shuffle.
orders.repartition("region").groupBy("region").count().collect()
What counts as a match
| Code | Shuffles | Why |
|---|---|---|
| `repartition("region").groupBy("region")` | 1 | Hash on region already gathers each region |
| `repartition(50, "region").groupBy("region")` | 1 | Same match, and the groupBy inherits the 50 partitions |
| `repartition("region").groupBy("region", "status")` | 1 | A region's rows sit together, so each region and status pair does too |
| `repartition("region", "status").groupBy("region")` | 2 | Status spreads 1 region over several partitions |
| `repartition(50).groupBy("region")` | 2 | Round-robin put every region everywhere |
| `groupBy("region").count().groupBy("region").max("count")` | 1 | The first groupBy left the rows hashed on region |
| `repartition("region").orderBy("region")` | 2 | A sort needs ranges, which a hash cannot provide |
Spark's test is whether the existing layout puts all rows that share the new grouping key in 1 partition. Hashing on a subset of the grouping columns passes it, hashing on extra columns fails it, and round-robin always fails. When the layout passes, its partition count carries forward, so repartition(50, "region") sets the task count of everything after it up to the next shuffle.
Shuffle files that outlive their job
The first collect() runs 400 map tasks that read plays and write partial sums, then 200 reduce tasks. The second runs the same query, finds all 400 map outputs registered, skips stage 0, and runs only the 200 reducers, so plays is never read a second time.
Where the reuse ends
The reuse belongs to 1 query. by_artist.filter(F.col("ms") > 0).collect() is a new query that plans its own shuffle, so both stages run again even though the rows feeding the shuffle are identical. The files are also temporary: Spark deletes them some time after the driver drops its last reference to the query that wrote them, and an executor that dies usually takes its files with it. Reuse you control on purpose comes from caching, covered in a later lesson.
Which of these 3 jobs run the 400 map tasks that read plays?
Capstone: the whole shuffle at once
With spark.sql.shuffle.partitions at 200, how many shuffles does this job run, and how many tasks run the aggregation?
The last question joins the partial pass from section 2 to the layout reuse in this one. A repartition has no aggregate to apply, so its map tasks write every row they receive, as they read it. Whatever runs after it, partial pass included, runs in the repartition's reduce tasks, on rows that have already crossed the network.
A teammate adds repartition("country") before groupBy("country").count() on the 1 billion plays in 400 partitions, so the groupBy adds no shuffle. Compared with the groupBy alone, how many rows cross the shuffle?
> Each December, Spotify Wrapped shows every listener their most-played artists of the year. Picture producing a version of it in Spark from 1 billion plays read as 400 partitions: drop plays under 30 seconds, count streams per listener and artist, sort the result by streams for a summary report, and collect the top rows twice while checking them.
(listener_id, artist_id) writes 2 files per map task, and its partial pass helps only as much as a listener repeats an artist inside 1 task.listener_id up front would let the groupBy reuse that layout, at the cost of shuffling every raw play and landing each heavy listener's plays on 1 reducer with outsized Shuffle Read.Follow a shuffle's bytes from a map task's local disk to a reducer's fetch, and learn when Spark can skip moving them.
- Category
- Spark
- Difficulty
- intermediate
- Duration
- 14 minutes
- Challenges
- 15 hands-on challenges
Topics covered: The Shuffle Write, The Shuffle Read, Combining Before the Shuffle, Hash vs Range Partitioning, Reusing Shuffle Output
Lesson Sections
- The Shuffle Write
A shuffle runs in 2 halves, split across a stage boundary. The first half, the shuffle write, is the last thing every task does in the stage before the wide transformation. Each of those tasks, called a map task, runs its pipelined narrow work, decides for every output row which downstream partition owns it, and writes the rows to its executor's local disk, grouped by that partition. The stage finishes only when the last map task has written. Choosing the target partition What lands on disk Each
- The Shuffle Read
The reduce stage waits for the whole map stage, since any map task might hold rows for any reducer. As each map task finishes, it reports to the driver where its data file lives and the size of each of its blocks. When all 400 have reported, the driver has a complete map of the shuffle, and the 200 reduce tasks can start. Reduce task 17 then fetches block 17 from each of the 400 data files. Blocks written on its own executor are read from local disk; the rest arrive over the network from the oth
- Combining Before the Shuffle
What decides the saving A map task can write at most 1 row per distinct key in its own partition, so the saving rests on a single ratio: rows per key within 1 task. Take a year of 1 billion plays read as 400 partitions, 2.5 million rows per map task, and group them 3 ways. Aggregates that combine poorly Check the ratio on real data
- Hash vs Range Partitioning (concepts: Shuffle Optimization)
Why a sort needs ranges Suppose the sample for 3 partitions yields cut points of 50 and 100. Rows then route like this: Which rule your code gets The partition count is part of the hash
- Reusing Shuffle Output
Spark skips a shuffle in 2 situations. Inside 1 query, it plans none when the data already sits partitioned the way the next operation needs. Across actions, it skips a map stage whose shuffle files are still on disk from an earlier job. Partitioning already in place The animation repartitions 9 orders by region into 3 partitions, then counts orders per region. Count the shuffles as it plays: there is 1, and each count is computed in the task where its region's rows landed. What counts as a matc