Skip to main content

Data Skew, Stragglers, and Broadcast vs Shuffle Joins

Level 6: Level 6: Cloud & Data Engineering Foundationsmedium26 mindata skewstragglersbroadcast joinshuffle joinautoBroadcastJoinThreshold

One hot key overloads one task and the whole stage waits on that straggler. For a huge-to-tiny join, broadcast the small side so the big table never shuffles (Spark auto-broadcasts under 10 MB).

Data skew and the straggler

Parallelism only helps if the work is spread evenly. Data skew is when one key has vastly more rows than the others: a NULL that swallows a third of the rows, one mega-customer, one hot product. In a group-by or join, all the rows for a key go to one task, so the task holding the giant key runs far longer than the rest. The whole stage cannot finish until that one straggler finishes, so a job that should take a minute takes twenty, and that one executor may run out of memory. You already surfaced a hot key like this in Level 5's join fan-out lesson, where a GROUP BY key COUNT(*) exposed the one customer_id with far more rows than the rest; that same count is the first skew diagnostic here.

You spot skew by comparing the slowest task to a typical task in its stage. A task running many times a typical task's duration is the tell. (Spark's Adaptive Query Execution, on by default since Spark 3.2, even detects and splits skewed partitions automatically, when a partition is both more than five times the median partition size and larger than 256 MB.) The beginner fixes are to salt the hot key (append a random suffix so it spreads across tasks), filter out the junk key (a NULL you do not need), or let AQE handle it.

One honest caveat about the exercise below: it divides each task's duration by the stage average, which is a one-line proxy you can write in SQL, but the straggler itself pulls that average up, so the ratio understates the skew. The slow task here runs about 2.6x the average, yet against a typical (non-straggler) task it is closer to 6x, which is what would trip AQE's five-times-the-median rule. When you can, compare to the median or a typical task, not the mean.

Broadcast join vs shuffle join

The most common join question a junior gets is "you are joining a huge fact table to a tiny lookup table, how do you avoid a shuffle." The answer is a broadcast join.

Table
If one side is small enough to broadcast, you avoid shuffling the huge table entirely. Spark auto-broadcasts under 10 MB by default.
join kindwhendata movement
broadcast joinone side is small (under ~10 MB)copy the small table to every executor; the big table never moves
shuffle (sort-merge) joinboth sides are largeshuffle both sides so same-key rows meet
If one side is small enough to broadcast, you avoid shuffling the huge table entirely. Spark auto-broadcasts under 10 MB by default.
  • If one side is small, the engine copies that whole table to every executor and each executor joins its slice of the big table locally. The huge table never moves. No shuffle.
  • If both sides are large, you cannot broadcast, so the engine shuffles both sides so matching keys meet. This is a sort-merge join, and it is the expensive default for big-to-big joins.

Spark decides automatically: any table under spark.sql.autoBroadcastJoinThreshold (default 10 MB, or 10485760 bytes) is broadcast. That default is really 10 MiB (10,485,760 bytes), so the byte comparison, not a rounded "10 MB", is what actually decides. Setting the threshold to -1 disables it. Knowing that number, and that it is the small side that gets broadcast, is a clean junior answer.

Reading join and skew data with SQL

The exercises compute both: a per-task straggler ratio (each task's duration over its stage average) that surfaces the skewed task, and, for a set of join inputs, which dimension tables are small enough to broadcast under the 10 MB threshold.

Common mistake: trying to broadcast a table that is not actually small. Only the small side of a join broadcasts, and broadcasting a large table copies it to every executor and runs them out of memory, so check the size against the threshold first.

Interview nuance: "huge fact table joined to a tiny dimension, avoid the shuffle" wants "broadcast the small table so the big one never moves." "One task is 10x slower than the rest" wants "data skew: a hot key overloaded one task; salt it, filter the null, or let AQE split it."

On a real platform this differs. Redshift co-locates join keys with a distribution key so matching rows already share a node; Snowflake runs elastic virtual warehouses over shared micro-partitions. The broadcast-vs-shuffle and skew reasoning is the same idea those systems express differently. The task_metrics and join_inputs tables here let you compute the decision the optimizer makes.

Sample data for this example
CREATE TABLE task_metrics (
  stage_id            INTEGER,
  task_id             INTEGER,
  input_rows          INTEGER,
  input_bytes         INTEGER,
  shuffle_write_bytes INTEGER,   -- bytes this task wrote to the shuffle (0 on the read stage)
  duration_s          REAL
);
INSERT INTO task_metrics (stage_id, task_id, input_rows, input_bytes, shuffle_write_bytes, duration_s) VALUES
  (0, 0, 2500000, 260000000, 90000000, 12.0),   -- stage 0: scan + map (narrow), balanced
  (0, 1, 2500000, 258000000, 89000000, 11.5),
  (0, 2, 2500000, 262000000, 91000000, 12.3),
  (0, 3, 2500000, 259000000, 90000000, 11.8),
  (1, 0, 1200000, 120000000, 0,  8.0),           -- stage 1: reduce after the shuffle (wide)
  (1, 1, 1300000, 130000000, 0,  8.5),
  (1, 2, 1250000, 125000000, 0,  8.2),
  (1, 3, 6000000, 600000000, 0, 47.0);           -- straggler: a skewed key landed on this task
Worked example (SQL)
-- Per stage, the average vs the slowest task. A high ratio means a straggler (skew).
SELECT stage_id,
       ROUND(AVG(duration_s), 1) AS avg_s,
       ROUND(MAX(duration_s), 1) AS max_s,
       ROUND(MAX(duration_s) / AVG(duration_s), 2) AS straggler_ratio
FROM task_metrics
GROUP BY stage_id
ORDER BY stage_id;

Apply

Your turn

The task this lesson builds to.

Write a query that returns each task in stage 1 with its duration and how many times the stage's average duration it ran, as (task_id, duration_s, x_stage_avg), slowest first, over task_metrics(stage_id, task_id, input_rows, input_bytes, shuffle_write_bytes, duration_s).

x_stage_avg is the task's duration_s divided by the average duration of stage 1 (a straggler shows a value well above 1). Keep only stage 1, round x_stage_avg to 2 decimals, ordered by duration_s descending.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 4 bonus drills.

Write a query that returns each dimension table and whether Spark would auto-broadcast it in a join to the events fact, as (table_name, size_mb, broadcast), smallest first, over join_inputs(table_name, role, size_bytes).

Consider only rows where role is 'dimension'. broadcast is 'yes' when the table is under the 10 MB autoBroadcastJoinThreshold (10485760 bytes) and 'no' otherwise. Treat 1 MB as 1,000,000 bytes for size_mb, round it to 2 decimals, ordered by size_bytes ascending.

3 hints and 1 automated check are waiting in the workspace.