The Word Count Shuffle Trap
An easy Spark interview practice problem on DataDriven. Write the Spark code and run it against the tables, with instant feedback.
- Domain
- Spark
- Difficulty
- easy
- Seniority
- L5
- Asked in
- Apple
Problem
Ever since the input tripled, the nightly tally of calls per response status that an on-call engineer owns has died with 48 GB of shuffle write and three executors out of memory, because it collects every single call for a status code onto one machine before counting them. She needs the same per-status call totals from api_calls, produced in a way that stays small no matter how many calls pile onto the busiest status code.
groupByKey works. Your cluster disagrees.
Tables
api_calls
Columns: call_id, endpoint, method, status, latency, user_id, call_time, err_msg
| call_id | endpoint | method | status | latency | user_id | call_time | err_msg |
|---|---|---|---|---|---|---|---|
| 8079 | /api/v1/orders | POST | 200 | 4.2 | 100 | 2026-02-02 01:07:00 | NULL |
| 8158 | /api/v2/products | PUT | 200 | 7.9 | 197 | 2026-03-03 02:14:00 | NULL |
| 8237 | /api/v1/search | DELETE | 201 | 11.6 | 294 | 2026-04-04 03:21:00 | NULL |
| 8316 | /api/v1/auth/login | PATCH | 204 | 15.3 | 391 | 2026-05-05 04:28:00 | NULL |
| 8395 | /api/v1/auth/logout | get | 301 | 19 | NULL | 2026-06-06 05:35:00 | NULL |
| 8474 | /api/v2/analytics | post | 400 | 22.7 | 585 | 2026-07-07 06:42:00 | Not found |
| 8553 | /api/v1/payments | GET | 401 | 26.4 | 682 | 2026-08-08 07:49:00 | Unauthorized |
| 8632 | /api/v1/users/ | POST | 403 | 30.1 | 779 | 2026-09-09 08:56:00 | Rate limit exceeded |
| 8711 | /api/v2/products/ | PUT | 404 | 33.8 | 876 | 2026-01-10 09:03:00 | Internal server error |
| 8790 | /api/v1/users | DELETE | 500 | 37.5 | NULL | 2026-02-11 10:10:00 | Bad gateway |
Expected output
| status | call_count |
|---|---|
| 200 | 518 |
| 201 | 15 |
| 401 | 18 |
| 404 | 14 |
| 502 | 13 |
Worked solution and explanation
This is word count with status codes standing in for words, and the whole question is where the adding happens. Everyone gets the right totals. What separates candidates is whether the sum runs before the shuffle or after it. If you gather first and add later, every single (status, 1) pair crosses the network, and the busiest key's entire value list piles into one executor's heap. With most traffic answering 200, that one key carries most of the job. This is how a job that ran fine for months starts killing executors in the quarter the data triples.
Why it only broke now
groupByKey has no combiner. It ships raw values and materializes each key's full Iterable on the reduce side, and only then does mapValues(sum) run. On a small input that list fits and nobody notices. At 2.1B records, the hottest key alone is over a hundred million boxed integers in a 16 GB heap, and the executors spend 85% of their time in garbage collection without freeing enough memory.
calls = spark.table("api_calls")
# every (status, 1) pair is shuffled; each status code's full list lands on one executor
status_counts = (
calls.rdd
.map(lambda row: (row.status, 1))
.groupByKey()
.mapValues(sum)
)Correct totals, fatal plan: no map-side combine.
The fix
Step 1: Name the operation as associative
A count is a sum of ones, and sums can be split: add within each partition, then add the partials. That property is what makes it safe to combine early. A median has no such property, which is why a median is the legitimate reason to gather values.
Step 2: Let the engine combine on the map side
On the RDD API that means reduceByKey(lambda a, b: a + b). On DataFrames, groupBy("status").agg(F.count("*")) already plans a partial HashAggregate per partition, then the Exchange, then a final merge. Either way, at most one partial per key per partition crosses the wire.
Step 3: Check the plan, not the output
Run explain() and look for two HashAggregate nodes around the Exchange. If the shuffle appears before any aggregation, you have rebuilt the incident.
calls = spark.table("api_calls")
status_counts = (
calls
.groupBy("status")
.agg(F.count("*").alias("call_count"))
)- Gather then add
groupByKey()thenmapValues(sum): the shuffle carries every raw record, and the reduce side holds each key's whole list in memory.- Add then gather
reduceByKeyor DataFramegroupBy().agg(): each partition emits one partial per key, and the reduce side adds a few hundred integers per key.
Common follow-up questions
- The 200 key still dominates one reduce task after the fix. What now? (Salting a skewed key and merging the salted partials.)
- How would you compute each status code's average `latency` without gathering values? (Carrying a `(sum, count)` pair through `aggregateByKey`.)
- What changes if you need the exact median `latency` per status code? (Non-associative aggregates and when gathering is justified.)