
Gowtham Potureddidata skew is the reason a distributed job that should finish in four minutes instead crawls for four...
data skew is the reason a distributed job that should finish in four minutes instead crawls for four hours while 199 of its 200 tasks sat idle an hour ago — it is the single most common performance pathology in Spark, Flink, Trino, and every other shuffle-based engine, and it is invisible until you learn where to look. The wall-clock time of a distributed stage is not the average task time; it is the slowest task time, so the moment one partition ends up holding a hundred times more rows than the others, that one task becomes a straggler that the entire cluster waits on while every other executor core burns money doing nothing. The uneven work is not caused by a slow machine or a bad network — it is caused by the shape of your data, where one key, one region, one NULL, or one guest-checkout account carries a disproportionate share of the rows.
This guide is the walkthrough you wished existed the first time a job "hung at 99%," or an interviewer asked "why does one task run forever, and how would you fix it?", or a groupBy that used to be instant started spilling to disk and then died with an out-of-memory error. It works through what partition skew actually is and how to spot the straggler in the stage timeline, why joins amplify skew when a hot key floods a single reducer, how salting and key redistribution spread that hot key across many partitions, how aggregation and groupBy skew differ from join skew and demand a two-stage combine, and how engine features — Spark AQE, skew-join hints, repartition, and bucketing — detect and prevent skew before it detonates. Each section pairs a teaching block with a worked interview answer: code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why the fix works.
When you want hands-on reps immediately after reading, drill the optimization practice library →, rehearse on the data-processing practice library →, and sharpen the grouping axis with the aggregation practice library →.
On this page
The one-sentence invariant: data skew is any distribution of rows across partitions where one (or a few) partitions hold vastly more data than the rest, so the task processing that partition runs far longer than its peers, and because a distributed stage cannot finish until its last task finishes, the whole job's wall-clock time collapses onto that single straggler regardless of how many cores are idle. Skew is not a slow disk, a slow node, or a slow network — those are hardware stragglers you fix with speculation. Data skew is a logical straggler baked into the shape of your keys, and no amount of extra executors will help, because the extra executors have nothing to do while the one overloaded task grinds through its outsized partition.
What skew actually is — the mental model.
groupBy, join, or repartition, each row lands in partition = hash(key) % numPartitions. Every row sharing a key lands in the same partition.guest owns 90% of the rows, the partition holding guest holds 90% of the work, and its task runs roughly 0.9 / (1/200) = 180x longer than a perfectly-balanced task would.The 2026 reality — every shuffle engine skews, and AQE only softens it.
groupBy both hash-partition by key, and a hot key produces a single monster task. Spark AQE (on by default since 3.2) auto-splits skewed join partitions but does nothing for skewed groupBy.keyBy; a hot key pins one subtask's slot at 100% while the rest idle, and backpressure propagates upstream.How to spot it — the Spark UI tells you in ten seconds.
What interviewers listen for.
Detailed explanation. The fastest diagnosis of skew is the Spark UI's stage detail page. Before you touch any code, you open the stage that is hanging, sort the task table by duration, and compare the max against the median. The gap tells you whether you have skew and how bad it is. Walk through the interpretation for a stalled join stage.
Question. Given the stage-page numbers below, decide whether this is data skew, and estimate the skew factor.
Input.
| Metric | Median task | Max task (task 47) |
|---|---|---|
| Duration | 2.1 s | 62 min (running) |
| Shuffle read size | 240 MB | 48.2 GB |
| Shuffle read records | 1.2 M | 214 M |
| Spill (disk) | 0 | 30 GB |
Code.
# Programmatic version of "read the stage page":
# pull task metrics from the Spark REST API and compute the skew factor.
import requests
APP = "application_1725500000000_0042"
STAGE = 12 # the hanging stage id
ATTEMPT = 0
base = f"http://spark-history:18080/api/v1/applications/{APP}"
tasks = requests.get(f"{base}/stages/{STAGE}/{ATTEMPT}/taskList",
params={"length": 100000}).json()
read_sizes = sorted(t["taskMetrics"]["shuffleReadMetrics"]["remoteBytesRead"]
+ t["taskMetrics"]["shuffleReadMetrics"]["localBytesRead"]
for t in tasks if t.get("taskMetrics"))
median = read_sizes[len(read_sizes) // 2]
mx = read_sizes[-1]
skew_factor = mx / max(median, 1)
print(f"tasks={len(read_sizes)} median_read={median/1e6:.0f} MB "
f"max_read={mx/1e9:.1f} GB skew_factor={skew_factor:.0f}x")
# → tasks=200 median_read=240 MB max_read=48.2 GB skew_factor=201x
Step-by-step explanation.
partition skew.Output.
| Verdict | Evidence | Skew factor |
|---|---|---|
| Data skew (not hardware) | exactly one long task, not a cluster of slow ones | ~200x |
| Fat partition confirmed | 48.2 GB shuffle read vs 240 MB median | ~200x |
| Memory pressure | 30 GB spill on the one task only | — |
| Action | reshape the key: broadcast, salt, or enable AQE skew join | — |
Rule of thumb. Diagnose skew before you fix it: open the stage page, sort tasks by duration, and divide the max shuffle-read size by the median. A ratio under 2x is healthy; over 10x means one hot partition; the size of the ratio picks the fix.
Detailed explanation. You don't have to wait for a job to hang to find skew — you can measure the key distribution directly. A single GROUP BY key COUNT(*) over the join or aggregation key, sorted descending, reveals the hot keys and lets you compute the skew factor offline. Every senior engineer runs this census before shipping a join on a new table.
SELECT key, COUNT(*) FROM t GROUP BY key ORDER BY 2 DESC — the top rows are your hot keys.max(count) / (total / distinct_keys) — how many times the hottest key exceeds a perfectly-even share.Question. Given an events table keyed by account_id, compute the skew factor and decide the fix.
Input.
| account_id | row_count |
|---|---|
| 0 (guest) | 900,000,000 |
| 88123 | 4,200,000 |
| 90277 | 3,900,000 |
| … (2.0 M other accounts) | ~50 each |
Code.
-- PostgreSQL: measure key skew directly from the table
WITH counts AS (
SELECT account_id, COUNT(*) AS n
FROM events
GROUP BY account_id
),
stats AS (
SELECT MAX(n) AS max_n,
SUM(n) AS total_n,
COUNT(*) AS distinct_keys,
SUM(n)::numeric / COUNT(*) AS even_share
FROM counts
)
SELECT max_n,
distinct_keys,
ROUND(even_share) AS rows_if_even,
ROUND(max_n / even_share, 1) AS skew_factor
FROM stats;
Step-by-step explanation.
counts CTE is the census: one row per key with its row count. For a real table you would TABLESAMPLE or run it on a partition to keep the census cheap, but the shape is the same.even_share = total_rows / distinct_keys is what each key would hold if the data were perfectly balanced. Here total ≈ 1.008 B rows over ~2 M keys ≈ 504 rows per key if even.skew_factor = max_n / even_share = 900,000,000 / 504 ≈ 1.79 M. The guest account alone holds nearly 90% of all rows — a catastrophic skew that will pin one task forever.accounts is small (2 M account dim rows, easily a few hundred MB), the first move is a broadcast join. If both sides were large, salting the guest key would be the move (section 3).0/NULL, a default tenant, a bot account — and naming it early makes the fix obvious.Output.
| Metric | Value |
|---|---|
| Hottest key | account_id = 0 (guest) |
| Rows on hottest key | 900,000,000 |
| Rows if perfectly even | ~504 |
| Skew factor | ~1,785,000x |
| Recommended fix | broadcast the small dim, or isolate + salt the guest key |
Rule of thumb. Measure skew from the data with a GROUP BY key COUNT(*) census and compute max_count / (total / distinct_keys). The hottest key almost always has a business meaning — a sentinel NULL, a default account, a bot — and naming it is half the fix.
Detailed explanation. Not every straggler is data skew. A single slow disk, a noisy-neighbour VM, or a garbage-collection storm can also make one task lag. The diagnostic difference is decisive: skew is reproducible on the same partition and shows a huge shuffle read; a hardware straggler is random across runs and shows normal input size. Confusing the two sends you down the wrong fix path (speculation vs reshaping keys).
Question. Two jobs each have one slow task. Classify each and pick the fix.
Input.
| Signal | Job A | Job B |
|---|---|---|
| Slow task index across 3 runs | always task 47 | task 12, then 88, then 3 |
| Slow task shuffle read vs median | 200x | 1.1x |
| Slow task GC time | normal | 40 s |
| Speculation helped? | no | yes |
Code.
# Classifier: skew vs hardware straggler, from task metrics
def classify_straggler(slow_task, median_read_bytes, median_dur_s):
read_ratio = slow_task["shuffle_read"] / max(median_read_bytes, 1)
dur_ratio = slow_task["duration_s"] / max(median_dur_s, 1)
if read_ratio >= 5:
return "DATA_SKEW: reshape the key (salt / broadcast / AQE)"
if dur_ratio >= 5 and read_ratio < 2:
# slow despite normal input → node/GC/disk problem
return "HARDWARE_STRAGGLER: enable spark.speculation"
return "BALANCED: look elsewhere (UDF cost, wide rows)"
print(classify_straggler({"shuffle_read": 48_200_000_000, "duration_s": 3720},
median_read_bytes=240_000_000, median_dur_s=2.1))
# → DATA_SKEW: reshape the key (salt / broadcast / AQE)
Step-by-step explanation.
hash(hot_key) % 200 always lands on the same partition, so the same task is always the victim. Its 200x shuffle read confirms data skew.spark.speculation for Job A would waste resources: the speculative copy of task 47 gets the identical 48 GB partition and is equally slow. That is why "just enable speculation" is a wrong answer for skew.Output.
| Job | Classification | Fix |
|---|---|---|
| A | data skew (deterministic, 200x read) | salt / broadcast / AQE skew join |
| B | hardware straggler (random, normal read) | spark.speculation=true |
Rule of thumb. A straggler that is always the same task index with a huge shuffle read is data skew — reshape the key. A straggler that is a random task each run with normal input is a hardware/GC problem — enable speculation. Never fix skew with speculation; the speculative copy inherits the same fat partition.
A senior interviewer often opens with: "A nightly Spark job that used to finish in six minutes now runs for over three hours. The Spark UI shows the last stage stuck at 999 of 1000 tasks complete, with one task still running. Walk me through how you'd confirm it's data skew rather than a slow node, quantify how bad it is, and identify the offending key — before you change any job code."
# skew_probe.py — confirm skew, quantify it, and name the hot key
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.getOrCreate()
df = spark.table("analytics.events") # the table feeding the slow stage
JOIN_KEY = "account_id" # the key the slow stage shuffles on
# 1. Per-key census — how many rows per key
census = (df.groupBy(JOIN_KEY)
.count()
.orderBy(F.desc("count")))
# 2. Skew statistics in one pass
stats = census.agg(
F.max("count").alias("max_rows"),
F.expr("percentile_approx(count, 0.5)").alias("median_rows"),
F.sum("count").alias("total_rows"),
F.count("*").alias("distinct_keys"),
).collect()[0]
even_share = stats["total_rows"] / stats["distinct_keys"]
skew_factor = stats["max_rows"] / even_share
print(f"distinct_keys = {stats['distinct_keys']:,}")
print(f"rows_if_even = {even_share:,.0f}")
print(f"max_rows = {stats['max_rows']:,} (skew_factor = {skew_factor:,.0f}x)")
# 3. Name the hot keys (top 5)
census.limit(5).show(truncate=False)
# 4. Prove it is the SAME partition every run (deterministic straggler)
NUM_SHUFFLE = int(spark.conf.get("spark.sql.shuffle.partitions", "200"))
hot_partition = (df.groupBy(JOIN_KEY).count()
.withColumn("partition",
F.pmod(F.hash(F.col(JOIN_KEY)), F.lit(NUM_SHUFFLE)))
.groupBy("partition")
.agg(F.sum("count").alias("rows_in_partition"))
.orderBy(F.desc("rows_in_partition")))
hot_partition.show(5, truncate=False)
Step-by-step trace.
| Step | What it computes | Result on this job |
|---|---|---|
| Census | rows per account_id
|
guest key = 900 M rows |
| Skew stats | max / median / even-share | max 900 M vs even ~504 |
| Skew factor | max_rows ÷ even_share | ~1.79 M x |
| Hot-key list | top 5 keys by count |
0 (guest) dominates |
| Partition probe |
hash(key) % 1000 per key |
one partition holds ~90% of rows |
groupBy(key).count()) to get rows per key; this is cheap relative to the failing stage and needs no code change to the pipeline.percentile_approx), total, distinct. The median being tiny while the max is enormous is the numerical signature of skew.even_share = total / distinct and skew_factor = max / even_share. A factor in the thousands proves the straggler is logical, not hardware.census.limit(5) — here account_id = 0, the guest-checkout sentinel. Now the straggler has a business explanation.pmod(hash(key), numPartitions) shows which shuffle partition each key maps to and confirms one partition absorbs ~90% of the rows — the same task index every run, which rules out a slow node.Output:
| Diagnosis output | Value |
|---|---|
| Is it skew? | yes — deterministic straggler, huge partition |
| Skew factor | ~1.79 M x over even distribution |
| Hot key |
account_id = 0 (guest checkout) |
| Fat partition share | ~90% of all rows in one partition |
| Next step | broadcast the account dim, or isolate + salt the guest key |
Why this works — concept by concept:
groupBy(key).count() is the ground-truth distribution of the shuffle key. Every skew fix depends on knowing which keys are hot and how hot; the census answers both without waiting for the job to fail again.percentile_approx computes it in one pass over billions of rows without a full sort.Optimization
Topic — optimization
Optimization problems on diagnosing stragglers and skew
The mental model in one line: a shuffle (sort-merge) join redistributes both sides so that every row sharing a join key lands in the same partition on the same task — which is exactly what makes the join correct, and exactly what makes a hot key catastrophic, because if one key owns most of the rows then one task receives most of the rows, spills, and runs forever while the rest of the cluster finishes in seconds. Join skew is the most common and most painful form of data skew, because joins are everywhere and the fat side is often a fact table with a natural sentinel key.
Why joins amplify skew.
fact JOIN dim ON fact.k = dim.k, both sides are repartitioned by k. All rows with k = guest go to one partition on each side, and their cross-product is computed on one task.OutOfMemoryError or Container killed by YARN for exceeding memory limits.The canonical symptom — one task runs forever.
Broadcast vs shuffle — the first fix to reach for.
spark.sql.autoBroadcastJoinThreshold and the broadcast() hint force it; setting it too high risks driver/executor OOM from an oversized broadcast.What interviewers listen for.
Detailed explanation. To fix skew you first have to see it. The clearest reproduction is a fact table with a dominant sentinel key joined to a dimension. Build a clicks fact where 90% of rows carry user_id = 0 (logged-out / guest traffic) and join it to a users dimension. The result is a textbook one-task-runs-forever stage.
clicks(click_id, user_id, ts, url) — 1 billion rows, 90% with user_id = 0.users(user_id, plan, country) — 5 million rows.clicks JOIN users ON clicks.user_id = users.user_id.user_id = 0 receives ~900 M fact rows on one task.Question. Write the naive join and predict which task becomes the straggler and why.
Input.
| Table | Rows | Skew |
|---|---|---|
| clicks | 1,000,000,000 | 90% user_id = 0
|
| users | 5,000,000 | uniform |
Code.
from pyspark.sql import SparkSession, functions as F
spark = (SparkSession.builder
.config("spark.sql.shuffle.partitions", "1000")
.config("spark.sql.adaptive.enabled", "false") # disable AQE to SEE the skew
.config("spark.sql.autoBroadcastJoinThreshold", "-1") # force shuffle join
.getOrCreate())
clicks = spark.table("web.clicks") # 1B rows, user_id=0 is 90%
users = spark.table("web.users") # 5M rows
# Naive shuffle sort-merge join — will produce a single monster task
joined = clicks.join(users, on="user_id", how="inner")
joined.groupBy("plan").count().show() # action that triggers the join
Step-by-step explanation.
autoBroadcastJoinThreshold = -1 forces a plain shuffle sort-merge join so the skew is visible rather than auto-handled — this is a teaching setup, not production config.clicks and users by user_id into 1000 shuffle partitions. Every row with user_id = 0 hashes to the same partition — call it partition 512.user_id = 0 dim row. The task for partition 512 must sort and merge 900 M rows; the other 999 tasks handle ~100 K rows each.users) or spread user_id = 0 (salting). Because users is small, broadcast is the right first move here.Output.
| Partition | Rows received | Task time |
|---|---|---|
512 (holds user_id=0) |
~900,000,000 | hours (spills, may OOM) |
| every other partition | ~100,000 | ~1-2 s |
| Stage wall clock | = task 512 | hours |
Rule of thumb. A shuffle join concentrates each key on one task, so a fact table with a dominant sentinel key (0, -1, NULL, "guest") always produces a straggler. Identify the sentinel first; it is almost always the hot key.
Detailed explanation. When one side of the join is small enough to fit in executor memory, broadcasting it eliminates the shuffle entirely — and with no key-based shuffle there is no skew, no matter how lopsided the fact side is. This is the first fix to try because it is the simplest and it fully removes the straggler.
users dimension to every executor; each executor joins its slice of clicks locally.clicks stays partitioned as-is; each task probes the in-memory users map. The user_id = 0 rows are spread across whatever partitions they already sit in.autoBroadcastJoinThreshold or use the explicit broadcast() hint; ensure the driver and executors have headroom for the broadcast size.Question. Rewrite the skewed join to broadcast the dimension, and explain why skew disappears.
Input.
| Side | Rows | Size | Broadcastable? |
|---|---|---|---|
| clicks (fact) | 1 B | ~120 GB | no (streamed) |
| users (dim) | 5 M | ~180 MB | yes (fits with headroom) |
Code.
from pyspark.sql import functions as F
from pyspark.sql.functions import broadcast
users_small = spark.table("web.users").select("user_id", "plan", "country")
# Broadcast the 180 MB dimension; no shuffle of the 1B-row fact
joined = clicks.join(broadcast(users_small), on="user_id", how="inner")
# Same downstream aggregation — now with no straggler
joined.groupBy("plan").count().show()
# Alternative: raise the auto-broadcast threshold so Spark chooses it itself
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", str(256 * 1024 * 1024)) # 256 MB
# Spark now broadcasts any side whose estimated size <= 256 MB automatically.
Step-by-step explanation.
broadcast(users_small) tells Spark to collect the 5 M-row dimension to the driver, then ship one copy to every executor. At ~180 MB this is cheap and fits comfortably in executor memory.clicks partition and probes the in-memory users hash map by user_id. There is no repartition-by-key step, so there is no fat partition.user_id = 0 rows are still 90% of the data — but they stay spread across the fact's original partitions (by input file / block), which are balanced. Balanced input partitions plus a local map lookup means balanced tasks.user_id, plan, country) shrinks the broadcast payload; broadcasting a wide dimension with 50 columns wastes memory and can push the driver toward OOM.autoBroadcastJoinThreshold to 256 MB lets Spark pick broadcast automatically whenever the estimated build side fits — but keep it below the point where a mis-estimated large side blows up the driver. The explicit broadcast() hint is safer when you know the small side is small.Output.
| Metric | Shuffle join | Broadcast join |
|---|---|---|
| Fact shuffled? | yes (120 GB) | no |
| Straggler task | 1 task, hours | none |
| Skew factor | ~200x | ~1x |
| Wall clock | hours | minutes |
| Risk | spill / OOM on fat task | driver OOM if dim too big |
Rule of thumb. If one side of a skewed join fits in memory (tens to a few hundred MB after column pruning), broadcast it and the skew vanishes because there is no key-based shuffle. Prune columns first to keep the broadcast small.
Detailed explanation. Before you ship any join on a large table, run a hot-key census so you know whether skew is coming and can pick the fix in advance. The census is a one-time GROUP BY join_key COUNT(*) that surfaces the top keys and their share of the total. It costs one cheap pass and saves a 3-hour failure in production.
Question. Run a hot-key census on clicks.user_id and translate the result into a fix decision.
Input.
| Column | Value |
|---|---|
| Table | web.clicks |
| Key | user_id |
| Total rows | 1,000,000,000 |
Code.
-- PostgreSQL / Spark SQL: top hot keys and their share of the table
SELECT user_id,
COUNT(*) AS n,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS pct_of_total
FROM web.clicks
GROUP BY user_id
ORDER BY n DESC
LIMIT 10;
Step-by-step explanation.
COUNT(*) per user_id is the row census; SUM(COUNT(*)) OVER () in the window computes the grand total in the same pass, so pct_of_total shows each key's share without a self-join.user_id = 0 is 90.0% of the table — a single key owning nine-tenths of the rows guarantees a straggler on any key-based shuffle.user_id = 0 at 90% is a broadcast-or-salt situation. Because the users dim is small, broadcast wins; if both sides were large, salt the user_id = 0 rows only.Output.
| user_id | n | pct_of_total | verdict |
|---|---|---|---|
| 0 (guest) | 900,000,000 | 90.00 | broadcast dim, or isolate + salt |
| 88123 | 4,200,000 | 0.42 | cold |
| 90277 | 3,900,000 | 0.39 | cold |
| … | … | < 0.4 | cold |
Rule of thumb. Run a hot-key census (GROUP BY key COUNT(*), with each key's percentage of the total) before shipping any large-table join. One key above ~5% of rows will skew; the census names it and picks the fix before production does.
A senior interviewer might ask: "You have a 2-billion-row transactions fact table joined to a 3-million-row merchants dimension. One merchant — an internal test merchant with merchant_id = 1 — accounts for 70% of all transactions, and the join stage runs for hours on a single task. The dimension is small. Walk me through detecting the hot key, the join rewrite you'd ship, and why it removes the straggler."
-- 1. Confirm the hot key with a census (Spark SQL / PostgreSQL)
SELECT merchant_id,
COUNT(*) AS n,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM transactions
GROUP BY merchant_id
ORDER BY n DESC
LIMIT 5;
-- merchant_id = 1 -> ~70% of rows (the internal test merchant)
# 2. Primary fix: broadcast the small dimension (no key shuffle -> no skew)
from pyspark.sql.functions import broadcast
tx = spark.table("fin.transactions") # 2B rows
merchants = spark.table("fin.merchants").select("merchant_id", # 3M rows
"merchant_name",
"category")
result = tx.join(broadcast(merchants), on="merchant_id", how="inner")
# 3. Defensive fix if the dimension were NOT broadcastable:
# isolate the hot key, broadcast just its one dim row, union the rest.
HOT = 1
tx_hot = tx.filter(tx.merchant_id == HOT)
tx_cold = tx.filter(tx.merchant_id != HOT)
dim_hot = merchants.filter(merchants.merchant_id == HOT) # 1 row -> tiny broadcast
res_hot = tx_hot.join(broadcast(dim_hot), on="merchant_id") # local, no shuffle
res_cold = tx_cold.join(merchants, on="merchant_id") # normal join, now balanced
result = res_hot.unionByName(res_cold)
Step-by-step trace.
| Step | Input | Effect |
|---|---|---|
| Census | 2 B tx rows | names merchant_id = 1 at ~70% |
| Broadcast dim | 3 M merchant rows (~150 MB) | ships dim to every executor |
| Fact shuffle | none | fact stays balanced by input block |
| Hot/cold split (defensive) | filter merchant_id = 1
|
isolates the fat key |
| Union | hot ∪ cold | one balanced result |
merchant_id = 1 owns ~70% of the fact — a guaranteed straggler on a shuffle join.merchants dimension (only three needed columns) to every executor; the 2 B-row fact is not shuffled, so no partition concentrates the hot key.merchant_id = 1 stay spread across the fact's balanced input partitions.= 1) and cold (!= 1): the hot side joins against a one-row broadcast dim (trivially small); the cold side does a normal, now-balanced shuffle join.unionByName recombines the two result streams into one DataFrame with identical schema and correct results — the hot key handled without a shuffle, the cold keys handled normally.Output:
| Metric | Naive shuffle join | Broadcast (+ isolation) |
|---|---|---|
| Straggler task | 1 task, hours | none |
| Fact shuffled | 2 B rows | 0 (broadcast) / cold-only (isolation) |
| Skew factor | ~700x | ~1x |
| Wall clock | hours | minutes |
| Correctness | same rows | identical rows |
Why this works — concept by concept:
merchant_id, merchant_name, category keeps the broadcast small and the driver safe; broadcasting a wide dimension is how "broadcast join" itself becomes a driver-OOM incident.Optimization
Topic — optimization
Optimization problems on skewed joins and broadcast
The mental model in one line: salting is the technique where you append a small random integer suffix (the "salt", 0..N-1) to the skewed join or group key so that a single hot key becomes N synthetic sub-keys that hash to N different partitions — and to keep the join correct you replicate the other side across all N salt values, join on the composite (key, salt), then drop the salt — trading a bounded N-fold blow-up of the small side for the elimination of the straggler. Salting is the go-to fix when broadcast does not apply because both sides are large.
The salting recipe for a join.
salt = floor(rand() * N) to each fact row, and build the composite key (k, salt). The hot key's rows now spread across N partitions instead of one.salt = 0..N-1. This is the cost: the dim grows N-fold.fact.(k, salt) = dim.(k, salt). Correct because every fact row's salt has a matching exploded dim row.salt; results are identical to the un-salted join.Choosing the salt factor N.
N ≈ ceil(hot_key_rows / target_partition_rows), where target is what a healthy partition should hold (e.g. a few million rows or a few hundred MB).Targeted (only-hot-key) salting — the senior refinement.
salt = 0), and explode the dim only for the hot keys. The dim grows by N × (hot key count) rows, not N × (all keys).What interviewers listen for.
Detailed explanation. The canonical salted join: two large tables where broadcast is impossible. Salt the fact's join key with N values, explode the dim by N, join on (user_id, salt), and drop the salt. This spreads the hot user_id = 0 across N partitions and removes the straggler.
clicks (1 B rows, user_id = 0 is 90%).user_features — 200 M rows, too big to broadcast.Question. Write the full salted join and show the composite-key mechanics.
Input.
| Side | Rows | Broadcastable? | Salt |
|---|---|---|---|
| clicks (fact) | 1,000,000,000 | no | add salt = rand()%64
|
| user_features (dim) | 200,000,000 | no | explode ×64 |
Code.
from pyspark.sql import functions as F
N = 64
# 1. Salt the fact side: each row gets a random salt in [0, N)
clicks_salted = (spark.table("web.clicks")
.withColumn("salt", (F.rand() * N).cast("int")))
# 2. Explode the dim side: every dim row is replicated for all N salts
dim = spark.table("web.user_features")
salts = spark.range(N).withColumnRenamed("id", "salt") # 0..N-1
dim_exploded = dim.crossJoin(salts) # dim rows × N
# 3. Join on the COMPOSITE key (user_id, salt) — spreads the hot key
joined = clicks_salted.join(
dim_exploded,
on=["user_id", "salt"],
how="inner",
)
# 4. Drop the salt — result is identical to the un-salted join
result = joined.drop("salt")
result.groupBy("plan").count().show()
Step-by-step explanation.
withColumn("salt", (rand()*N).cast("int")) gives every fact row a random salt in [0, 64). The 900 M rows with user_id = 0 now split into 64 groups of ~14 M, each with a distinct composite key (0, 0), (0, 1), … (0, 63).dim.crossJoin(salts) replicates every dim row 64 times, once per salt value. Now (user_id=0, salt=37) on the fact has a matching (user_id=0, salt=37) on the dim.["user_id", "salt"] is correct because the salt was added to both sides consistently: a fact row with salt 37 only matches the dim replica with salt 37, and there is exactly one such replica per original dim row.salt yields exactly the rows the naive join would have produced — salting changes only the physical partitioning, never the logical result. The cost is the 64x explosion of the dim (200 M → 12.8 B rows in the exploded intermediate), which is why targeted salting matters at scale.Output.
| Composite key | Rows | Partition |
|---|---|---|
| (0, 0) | ~14,000,000 | balanced |
| (0, 1) | ~14,000,000 | balanced |
| … (0, 63) | ~14,000,000 | balanced |
| (88123, 0..63) | ~65,000 each | balanced |
Rule of thumb. Salting a join is a three-step contract: salt the skewed side, explode the other side by the same N, join on (key, salt), then drop the salt. Forgetting to explode the other side silently drops rows — it is the number-one salting bug.
Detailed explanation. Naive salting explodes the entire dimension N-fold even though only the hot keys need spreading. Targeted salting fixes that: salt (and explode) only the known hot keys, and leave the millions of cold keys with a fixed salt of 0. The dim grows by N × hot_key_count rows instead of N × all_keys — a huge saving when the dim is large.
hot = {0} (just the guest sentinel).0..N-1; cold rows get salt 0.0 only.Question. Rewrite the salted join to spread only user_id = 0, leaving cold keys untouched.
Input.
| Key class | Fact salt | Dim explosion |
|---|---|---|
hot (user_id = 0) |
rand()%N |
×N |
| cold (all others) | 0 |
×1 (salt 0) |
Code.
from pyspark.sql import functions as F
N = 64
HOT = [0] # from the census; the guest sentinel
# 1. Fact: hot rows get a random salt; cold rows get salt 0
clicks = spark.table("web.clicks")
clicks_salted = clicks.withColumn(
"salt",
F.when(F.col("user_id").isin(HOT), (F.rand() * N).cast("int"))
.otherwise(F.lit(0)),
)
# 2. Dim: hot dim rows explode x N; cold dim rows keep salt 0
dim = spark.table("web.user_features")
salts = spark.range(N).withColumnRenamed("id", "salt")
dim_hot = (dim.filter(F.col("user_id").isin(HOT))
.crossJoin(salts)) # x N (few keys)
dim_cold = (dim.filter(~F.col("user_id").isin(HOT))
.withColumn("salt", F.lit(0))) # x 1
dim_salted = dim_hot.unionByName(dim_cold)
# 3. One composite-key join handles both classes
result = (clicks_salted
.join(dim_salted, on=["user_id", "salt"], how="inner")
.drop("salt"))
Step-by-step explanation.
user_id is in the hot list get a random salt; every cold row gets salt 0. Cold keys were never skewed, so they need no spreading.dim_hot (only the hot keys) is exploded ×N; dim_cold (all the millions of cold keys) is tagged with salt 0 and not replicated. The union is the full salted dim.dim_hot is 1 × 64 = 64 rows and dim_cold is ~200 M rows — the dim barely grew. Naive salting would have produced 200 M × 64 = 12.8 B dim rows; targeted salting produces 200 M + 64.(user_id, salt) handles both classes: hot fact rows (salt 0..63) match the 64 exploded hot dim rows; cold fact rows (salt 0) match the salt-0 cold dim rows. No post-join branching or union is needed on the result.salt gives identical results to the naive join, at a fraction of the shuffle volume — the hot key is spread across 64 partitions while the cold keys are untouched. This is the production-grade salting pattern.Output.
| Key class | Fact rows | Dim rows after salting |
|---|---|---|
hot (user_id=0) |
900 M, spread ×64 | 64 |
| cold (others) | 100 M, salt 0 | ~200 M |
| Dim total | — | ~200 M + 64 (vs 12.8 B naive) |
Rule of thumb. Salt only the hot keys the census names; give cold keys a fixed salt of 0 and do not explode their dim rows. Targeted salting keeps the dimension near its original size while still spreading the straggler.
Detailed explanation. N is not a magic number — it is derived from the skew. Pick N so that the hottest key's rows, once divided by N, fit within a healthy partition size. Too small and the sub-keys are still fat; too large and you create per-task overhead and needlessly explode the dim.
N = ceil(hot_key_rows / target_partition_rows).Question. Compute N for the guest key and verify the resulting sub-key size.
Input.
| Quantity | Value |
|---|---|
| Hot key rows | 900,000,000 |
| Target rows / partition | 4,000,000 |
| Cold key max rows | 4,200,000 |
Code.
import math
hot_key_rows = 900_000_000
target_partition = 4_000_000 # healthy partition size (rows)
N = math.ceil(hot_key_rows / target_partition)
N = 1 << (N - 1).bit_length() # round up to next power of two (nicety)
per_subkey = hot_key_rows / N
print(f"N = {N} rows_per_subkey = {per_subkey:,.0f}")
# → N = 256 rows_per_subkey = 3,515,625
# sanity: sub-key size should be <= the largest cold key, so no residual skew
print("balanced?" , per_subkey <= 4_200_000) # → True
Step-by-step explanation.
ceil(900M / 4M) = 225. Rounding up to the next power of two gives N = 256 (a convenience, not a requirement).900M / 256 ≈ 3.52 M rows — below the 4 M target and below the largest cold key (4.2 M), so the former straggler is now no fatter than a normal partition.per_subkey <= max_cold_key is the correctness test for N: if the salted hot sub-keys are still larger than the biggest cold key, N is too small and skew remains. Here 3.52 M ≤ 4.2 M passes.Output.
| Quantity | Value |
|---|---|
| Computed N (ceil) | 225 |
| N rounded to power of two | 256 |
| Rows per hot sub-key | ~3,515,625 |
| Balanced vs cold keys? | yes (3.52 M ≤ 4.2 M) |
Rule of thumb. Size the salt factor as N = ceil(hot_key_rows / target_partition_rows), then verify each salted sub-key is no larger than your biggest cold key. N should track the skew factor — larger for hotter keys, not a fixed constant.
A senior interviewer might ask: "You must join two large tables — a 5-billion-row impressions fact and a 400-million-row campaigns dimension — on campaign_id. Neither fits in memory to broadcast, and one campaign (a house/default campaign, campaign_id = -1) carries 60% of impressions. The join runs for hours on one task. Design the salted join: how you'd salt, how you'd size N, how you keep it correct, and how you'd avoid exploding the whole 400 M-row dimension."
from pyspark.sql import functions as F
import math
# 1. Size N from the census: hot key = -1 with ~3B rows; target ~5M/partition
hot_key_rows = 3_000_000_000
target_partition = 5_000_000
N = 1 << (math.ceil(hot_key_rows / target_partition) - 1).bit_length() # -> 1024
HOT = [-1]
imp = spark.table("ads.impressions") # 5B rows
cmp = spark.table("ads.campaigns") # 400M rows
# 2. Salt the fact: hot key random salt, cold keys salt 0 (targeted)
imp_salted = imp.withColumn(
"salt",
F.when(F.col("campaign_id").isin(HOT), (F.rand() * N).cast("int"))
.otherwise(F.lit(0)),
)
# 3. Explode only the hot dim rows x N; cold dim rows keep salt 0
salts = spark.range(N).withColumnRenamed("id", "salt")
cmp_hot = cmp.filter(F.col("campaign_id").isin(HOT)).crossJoin(salts) # 1 x 1024
cmp_cold = cmp.filter(~F.col("campaign_id").isin(HOT)).withColumn("salt", F.lit(0))
cmp_salted = cmp_hot.unionByName(cmp_cold) # ~400M + 1024
# 4. Composite-key join, then drop the salt -> identical result, no straggler
result = (imp_salted
.join(cmp_salted, on=["campaign_id", "salt"], how="inner")
.drop("salt"))
result.groupBy("category").agg(F.count("*").alias("impressions")).show()
Step-by-step trace.
| Step | Input | Effect |
|---|---|---|
| Size N | hot = 3 B rows, target 5 M | N = 1024 |
| Salt fact |
campaign_id = -1 rows |
random salt 0..1023 |
| Explode dim | 1 hot campaign row | ×1024 = 1024 rows |
| Cold dim | ~400 M rows | salt 0, not exploded |
| Composite join | (campaign_id, salt) |
balanced partitions |
| Drop salt | join result | identical to naive join |
-1 holds ~3 B rows (60% of 5 B); dividing by a 5 M target and rounding to a power of two gives N = 1024.campaign_id = -1 rows get a random salt in [0, 1024); all cold campaigns get salt 0. The 3 B hot rows now split into 1024 sub-keys of ~2.9 M rows each.0. The dimension grows by 1024 rows total — not by 1024×.(campaign_id, salt) matches hot fact rows to the 1024 exploded hot dim rows and cold fact rows to the salt-0 cold dim rows — one join, both classes, correct.Output:
| Metric | Naive shuffle join | Salted join |
|---|---|---|
| Hot partition rows | ~3,000,000,000 (1 task) | ~2,900,000 × 1024 tasks |
| Dim size after salting | 400 M | 400 M + 1024 |
| Straggler | 1 task, hours | none |
| Skew factor | ~600x | ~1x |
| Result correctness | baseline | identical |
Why this works — concept by concept:
salt = rand()%N turns one hot key into N synthetic sub-keys that hash to N partitions, so the fat partition is split N ways. This is the core mechanism that dissolves the straggler.campaign_id = -1 means the dimension grows by 1024 rows, not 1024× (which would be 400 billion rows). The census makes this safe because the hot set is a tiny, known list.N = ceil(hot_rows / target) makes each sub-key no fatter than a healthy partition; a smaller N leaves residual skew, a larger N wastes tasks and dim explosion. N tracks the data, not a constant.Data processing
Topic — data-processing
Data-processing problems on salting and redistribution
The mental model in one line: aggregation skew is groupBy/reduceByKey skew — after the shuffle, all rows for one group land on one task, so a dominant group makes one reducer do most of the work — and the fix depends on the aggregate: algebraic aggregates (SUM, COUNT, MIN, MAX, AVG) can be partially aggregated map-side and then combined with a two-stage salted groupBy, while holistic aggregates (COUNT DISTINCT, MEDIAN) cannot be partially combined and need approximation or a different plan. Aggregation skew is subtler than join skew because Spark AQE does not auto-fix it — you own the fix.
Why groupBy skews.
df.groupBy("country").sum("amount") repartitions by country; every row for country = US lands on one task. If US is 80% of the rows, one task processes 80% of them.groupByKey, all raw rows for a group are shuffled to one task before aggregation — the fat group both floods the network and blows the reducer's memory.groupBy needs an explicit fix.Partial (map-side) aggregation — the free win for algebraic aggregates.
reduceByKey does this; groupByKey does not.df.groupBy(...).sum() inserts a partial aggregate; the raw-row hazard is mostly an RDD groupByKey footgun. Still, a single mega-group can leave the final combine lopsided, which is where two-stage salting comes in.Two-stage salted aggregation — for when partial aggregation isn't enough.
salt = rand()%N, group by (key, salt), and aggregate. The hot group splits into N partial groups on N tasks.key and combine them (SUM of partial SUMs, SUM of partial COUNTs). The final combine handles only N partials per key — tiny.sum(sums)/sum(counts). Holistic aggregates do not.Holistic aggregates — COUNT DISTINCT and friends.
distinct(A) + distinct(B) ≠ distinct(A ∪ B) because of overlap. Two-stage salting silently over-counts.approx_count_distinct), which is mergeable; (b) two-stage distinct — first distinct the (key, value) pairs (spreads the work), then COUNT; (c) accept a single reducer for the exact answer if the distinct set is small.What interviewers listen for.
approx_count_distinct (HLL) for skewed COUNT DISTINCT? — senior signal.reduceByKey over groupByKey (map-side combine) in RDD code? — required answer.Detailed explanation. The canonical aggregation-skew fix: a groupBy(country).sum(amount) where US dominates. Split into two stages — salt and partial-sum by (country, salt), then strip the salt and sum the partials by country. The hot group's work spreads across N tasks in stage 1; stage 2 combines only N partials per country.
SELECT country, SUM(amount) FROM sales GROUP BY country; US = 80% of rows.(country, salt) — US splits into N sub-groups.country, SUM the partial sums.sum(partial sums) = total sum.Question. Rewrite the skewed SUM as a two-stage salted aggregation.
Input.
| country | rows | share |
|---|---|---|
| US | 800,000,000 | 80% |
| GB | 60,000,000 | 6% |
| … | … | 14% |
Code.
from pyspark.sql import functions as F
N = 64
sales = spark.table("fin.sales")
# Stage 1: salt + partial aggregate by (country, salt)
stage1 = (sales
.withColumn("salt", (F.rand() * N).cast("int"))
.groupBy("country", "salt")
.agg(F.sum("amount").alias("partial_sum")))
# Stage 2: strip salt, combine partials by country
result = (stage1
.groupBy("country")
.agg(F.sum("partial_sum").alias("total_amount")))
result.orderBy(F.desc("total_amount")).show()
# Contrast: the naive one-stage version that creates the straggler
naive = sales.groupBy("country").agg(F.sum("amount").alias("total_amount"))
# 'US' -> one reducer processes 800M rows' worth of partials on a single task
Step-by-step explanation.
[0, 64) and groups by (country, salt). The 800 M US rows split into 64 groups keyed (US, 0)…(US, 63), each ~12.5 M rows, landing on up to 64 different tasks. Each task computes a partial SUM for its sub-group.groupBys carries only country × salt partials (a few thousand rows), not raw sales rows.country alone and sums them: SUM(partial_sum) over the 64 US partials yields the exact total for US. The final combine touches only 64 rows per country — no straggler.US rows' aggregation to a single reducer; even with map-side partials, a single dominant group can leave the final task lopsided and slow. Two-stage salting removes that by spreading stage 1 across N tasks.Output.
| country | total_amount |
|---|---|
| US | 4,012,776,540.00 |
| GB | 301,225,110.00 |
| … | … |
Rule of thumb. For a skewed SUM/COUNT/MIN/MAX/AVG, use two stages: salt + partial-aggregate by (key, salt), then strip the salt and combine partials by key. The final combine sees only N partials per group, so the dominant group never lands whole on one reducer.
Detailed explanation. In the RDD API, groupByKey shuffles every raw value to the reducer and only then aggregates — catastrophic under skew because the hot key's millions of raw values flood one task's memory. reduceByKey combines values map-side before the shuffle, so the hot key's task receives one partial per upstream partition. The choice is the single biggest RDD-level skew lever.
groupByKey. Shuffles raw values; hot key → all raw values on one reducer → OOM.reduceByKey. Combines map-side; hot key → one partial per partition on the reducer → safe.groupByKey to then reduce; use reduceByKey/aggregateByKey.Question. Show the two implementations of a per-key sum and explain the shuffle difference under skew.
Input.
| key | raw values | share |
|---|---|---|
| hot | 800,000,000 | 80% |
| others | 200,000,000 | 20% |
Code.
rdd = spark.sparkContext.parallelize([]) # (key, amount) pairs, 1B of them
# BAD under skew: groupByKey shuffles all 800M raw 'hot' values to one reducer
bad = (rdd
.groupByKey() # shuffle carries raw values
.mapValues(lambda vals: sum(vals))) # reducer holds the whole iterable
# GOOD: reduceByKey combines map-side; reducer sees ~1 partial per partition
good = rdd.reduceByKey(lambda a, b: a + b) # shuffle carries partial sums
# aggregateByKey when the combine type differs from the value type
good2 = rdd.aggregateByKey(
0, # zero value
lambda acc, v: acc + v, # seqOp (map-side, within partition)
lambda a, b: a + b, # combOp (across partitions)
)
Step-by-step explanation.
groupByKey performs no map-side combine: it shuffles every (hot, value) pair to the reducer holding hot. That reducer must materialise an iterable of 800 M values before sum runs — the fat group blows its memory and the task spills or OOMs.reduceByKey applies the combine function a + b within each partition first, so each of the 1000 upstream partitions sends the reducer a single partial sum for hot. The reducer combines ~1000 partials instead of 800 M raw values — a 800,000x reduction in shuffled hot data.reduceByKey that reducer's input is tiny (1000 partials), so there is no memory blow-up and no multi-hour task. Map-side combine is what makes the difference under skew.aggregateByKey generalises reduceByKey when the accumulator type differs from the value type (e.g. building a (sum, count) tuple for AVG). It has the same map-side-combine benefit; the seqOp runs within a partition and the combOp across partitions.reduceByKey beats groupByKey is a required signal that you understand where the shuffle volume comes from.Output.
| Approach | Data shuffled for hot key | Reducer memory |
|---|---|---|
| groupByKey | ~800,000,000 raw values | blows up (spill/OOM) |
| reduceByKey | ~1000 partial sums | trivial |
| aggregateByKey | ~1000 partial accumulators | trivial |
Rule of thumb. In RDD code, never groupByKey().mapValues(reduce) — use reduceByKey or aggregateByKey so values combine map-side before the shuffle. Under skew the difference is a working job versus an OOM on the hot key's reducer.
Detailed explanation. COUNT DISTINCT is holistic — you cannot combine two partial distinct counts because they may share values. Two-stage salting silently over-counts. The production fix is HyperLogLog (approx_count_distinct), whose sketches are mergeable, so the salted two-stage plan becomes correct (within a small error bound). When you need exactness, first distinct the (key, value) pairs to spread the work, then count.
SUM(partial distinct counts) ≠ true distinct count (overlap double-counts).approx_count_distinct merges sketches exactly; error ~2%.DISTINCT (key, value) first (spreads), then COUNT per key.Question. Compute distinct visitors per country when US dominates, without a single-reducer straggler.
Input.
| country | rows | distinct visitors |
|---|---|---|
| US | 800,000,000 | ~40,000,000 |
| others | 200,000,000 | ~30,000,000 |
Code.
from pyspark.sql import functions as F
visits = spark.table("web.visits") # (country, visitor_id)
# APPROXIMATE (recommended for skew): HLL sketches merge correctly
approx = (visits
.groupBy("country")
.agg(F.approx_count_distinct("visitor_id", 0.02) # ~2% error
.alias("distinct_visitors")))
# EXACT, skew-safe: distinct the pairs first (spreads work), then count
exact = (visits
.select("country", "visitor_id").distinct() # heavy shuffle, but spread
.groupBy("country")
.agg(F.count("*").alias("distinct_visitors")))
# WRONG under skew: salting + SUM of partial distinct counts double-counts overlaps
Step-by-step explanation.
countDistinct("visitor_id") grouped by country routes all 800 M US rows to one reducer, which must build a 40 M-element distinct set in memory — a classic aggregation straggler that spills and slows.approx_count_distinct uses HyperLogLog: each partition builds a small fixed-size sketch of the visitors it saw, and sketches merge exactly (union of registers), so the hot group's work spreads across partitions and combines cheaply. The trade is ~2% error, tunable via the second argument.SELECT DISTINCT country, visitor_id first deduplicates the (country, visitor_id) pairs — this shuffle is heavy but spread across the full pair space, not concentrated on country alone — then a cheap COUNT(*) per country counts the surviving rows.COUNT(DISTINCT ...) per (country, salt), then SUM the partial distinct counts — double-counts any visitor who appears in multiple salt buckets, so US comes out inflated. Never SUM partial distinct counts.Output.
| country | distinct_visitors (HLL) | distinct_visitors (exact) |
|---|---|---|
| US | 40,312,880 (±2%) | 40,205,117 |
| GB | 6,001,240 (±2%) | 5,998,733 |
| … | … | … |
Rule of thumb. COUNT DISTINCT is holistic — never combine partial distinct counts. Use approx_count_distinct (HLL sketches merge correctly) for skewed distinct counts, or a two-stage DISTINCT (key, value) then COUNT when you need the exact answer.
A senior interviewer might ask: "A daily SELECT country, SUM(revenue), COUNT(*) FROM orders GROUP BY country job has started spilling and running for hours. Profiling shows country = 'US' is 85% of the rows. Spark AQE is enabled but hasn't helped. Explain why AQE doesn't fix this, then design a skew-safe aggregation that computes the exact SUM and COUNT without a single-reducer straggler — and note how you'd handle a COUNT DISTINCT of buyers in the same query."
from pyspark.sql import functions as F
N = 128
orders = spark.table("fin.orders") # (country, revenue, buyer_id), US = 85%
# Stage 1: salt + partial-aggregate the ALGEBRAIC parts by (country, salt)
stage1 = (orders
.withColumn("salt", (F.rand() * N).cast("int"))
.groupBy("country", "salt")
.agg(F.sum("revenue").alias("partial_rev"),
F.count("*").alias("partial_cnt")))
# Stage 2: strip salt, combine partials by country (SUM of sums, SUM of counts)
agg_exact = (stage1
.groupBy("country")
.agg(F.sum("partial_rev").alias("total_revenue"),
F.sum("partial_cnt").alias("order_count")))
# COUNT DISTINCT is holistic -> use HLL (mergeable), computed separately and joined
distinct_buyers = (orders
.groupBy("country")
.agg(F.approx_count_distinct("buyer_id", 0.02)
.alias("distinct_buyers")))
result = agg_exact.join(distinct_buyers, on="country", how="inner")
result.orderBy(F.desc("total_revenue")).show()
Step-by-step trace.
| Step | Input | Effect |
|---|---|---|
| Why not AQE | skewed groupBy, not a join |
AQE skew-split is SMJ-only |
| Stage 1 salt |
US = 85% of rows |
splits US into 128 sub-groups |
| Stage 1 agg | (country, salt) |
partial SUM + COUNT per sub-group |
| Stage 2 combine | 128 partials per country | exact SUM and COUNT |
| Distinct buyers | holistic | HLL sketch, mergeable, ~2% error |
groupBy aggregation shuffle is not a join, so AQE leaves the dominant US group on one reducer. The engineer owns this fix.US rows split into 128 (US, salt) sub-groups across up to 128 tasks, each computing a partial SUM and COUNT.SUM(partial_rev) and SUM(partial_cnt) give the exact total revenue and order count — no reducer ever sees more than 128 partials per group.approx_count_distinct (HLL), whose sketches merge across partitions correctly, spreading the US work without a straggler.country into one row per country — exact SUM/COUNT plus an approximate distinct-buyer count, all without a single-reducer straggler.Output:
| country | total_revenue | order_count | distinct_buyers |
|---|---|---|---|
| US | 4,012,776,540.00 | 850,000,000 | 40,312,880 (±2%) |
| GB | 301,225,110.00 | 60,000,000 | 6,001,240 (±2%) |
| … | … | … | … |
Why this works — concept by concept:
groupBy is outside AQE's remit is why you must salt it yourself.groupBy (key, salt)) spreads the dominant group across N tasks; stage 2 (groupBy key) combines only N partials per group. For algebraic aggregates the arithmetic is exact.Aggregation
Topic — aggregation
Aggregation problems on skewed group-by
The mental model in one line: Spark AQE (Adaptive Query Execution) uses runtime shuffle statistics to detect a partition that is far larger than the median and automatically split it into several balanced sub-partitions handled by separate tasks — which fixes skewed sort-merge joins with zero code change — but AQE does nothing for groupBy skew, so the durable answer is prevention: repartition on a good key, pre-aggregate, and bucket your tables so the skew never forms. Knowing exactly what AQE does and does not cover is the difference between "I turned on a flag" and "I understand my query plan."
What AQE does for skew.
skewedPartitionFactor × median and larger than skewedPartitionThresholdInBytes.Enabling and tuning AQE.
spark.sql.adaptive.enabled=true (default in 3.2+) and spark.sql.adaptive.skewJoin.enabled=true.spark.sql.adaptive.skewJoin.skewedPartitionFactor (default 5) and spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes (default 256 MB). A partition must exceed both to be split.spark.sql.adaptive.advisoryPartitionSizeInBytes (default 64 MB) guides the target sub-partition size.Skew-join hints — telling the optimizer what you know.
SKEW hint (Databricks / vendor Spark). SELECT /*+ SKEW('t', 'key') */ … tells the optimizer which column and values are skewed, so it applies the salting transform for you.broadcast() hint. Forces a broadcast join when you know a side is small, sidestepping skew entirely.Prevention — beating skew before it forms.
df.repartition(1000, "well_distributed_col") balances partitions when the natural key is skewed; repartition by a composite (key, salt) for a persistently hot key.groupBy upstream shrinks a fat key's row count before it can skew a downstream join.bucketBy(n, "key") on write pre-shuffles the table by key so repeated joins skip the shuffle — but a skewed key still buckets unevenly, so bucket on a well-distributed column.user_id = 0 is meaningless to the join (guest traffic that has no user features), filter it before the join instead of salting it.What interviewers listen for.
skewedPartitionFactor and skewedPartitionThresholdInBytes) a partition must exceed? — senior signal.Detailed explanation. For a skewed sort-merge join, AQE can remove the straggler with no code change — you just configure it. Walk through enabling AQE skew handling and tuning the thresholds so a 48 GB fat partition gets split into balanced sub-partitions.
skewedPartitionFactor / threshold if a real skew isn't being split; raise them if AQE is over-splitting.AQEShuffleRead with "skewed" sub-partitions.Question. Configure AQE so a join whose fat partition is 48 GB (median 240 MB) gets split.
Input.
| Setting | Default | This job |
|---|---|---|
| adaptive.enabled | true | true |
| skewJoin.enabled | true | true |
| skewedPartitionFactor | 5 | 5 |
| skewedPartitionThresholdInBytes | 256 MB | 256 MB |
Code.
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
# A partition is 'skewed' if BOTH hold:
# size > skewedPartitionFactor * median (48 GB > 5 * 240 MB = 1.2 GB ✓)
# size > skewedPartitionThresholdInBytes (48 GB > 256 MB ✓)
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256m")
# Target size for each split sub-partition
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128m")
# No code change to the join itself:
joined = spark.table("web.clicks").join(spark.table("web.user_features"), on="user_id")
joined.groupBy("plan").count().show()
Step-by-step explanation.
adaptive.enabled and skewJoin.enabled turns on AQE's runtime skew handling for sort-merge joins. Both are defaults in Spark 3.2+, but setting them explicitly documents intent and covers older clusters.> 5 × median (48 GB > 1.2 GB ✓) and > 256 MB (48 GB > 256 MB ✓). The double condition prevents splitting partitions that are merely a bit above median but still small.~48 GB / 128 MB ≈ 375 sub-partitions of ~128 MB each (guided by advisoryPartitionSizeInBytes). AQE replicates the matching user_id = 0 row from the other side to each sub-partition so the join stays correct.AQEShuffleRead node annotated with "skewed" and the increased partition count. If a known skew isn't split, lower skewedPartitionFactor (e.g. to 2) or the byte threshold; if AQE over-splits small partitions, raise them.Output.
| Metric | AQE off | AQE skew join on |
|---|---|---|
| Fat partition | 48 GB, 1 task | split into ~375 sub-tasks |
| Straggler | 62 min | none |
| Code change | — | zero (config only) |
| Applies to | — | sort-merge joins only |
Rule of thumb. For skewed sort-merge joins, enable adaptive.skewJoin and remember a partition must exceed both skewedPartitionFactor × median and skewedPartitionThresholdInBytes to be split. Lower the factor if a real skew isn't being handled; AQE fixes join skew with zero code change.
Detailed explanation. The most durable fix is to never form the fat partition. Repartitioning on a composite key spreads a hot key at read time, and bucketing a table on a well-distributed column pre-shuffles it so repeated joins skip the shuffle entirely. Prevention removes skew from every downstream query, not just one.
repartition(n, expr) to balance partitions; use a composite (key, salt) for a persistently hot key.bucketBy(n, "col") on write; joins/aggregations on col become shuffle-free — but bucket on a balanced column, or the buckets themselves skew.Question. Prevent the clicks-side skew before the join with repartition, and bucket the dimension for repeated joins.
Input.
| Technique | Target | Effect |
|---|---|---|
| repartition (key, salt) | clicks | balances hot key at read |
| bucketBy(1024, user_id) | user_features | shuffle-free repeated joins |
| pre-aggregate | clicks | fewer hot-key rows downstream |
Code.
from pyspark.sql import functions as F
N = 64
# 1. Repartition clicks on a composite (user_id, salt) so the hot key spreads
clicks_balanced = (spark.table("web.clicks")
.withColumn("salt", (F.rand() * N).cast("int"))
.repartition(1024, "user_id", "salt"))
# 2. Bucket the dimension once on write; future joins on user_id skip the shuffle
(spark.table("web.user_features")
.write.mode("overwrite")
.bucketBy(1024, "user_id")
.sortBy("user_id")
.saveAsTable("web.user_features_bucketed"))
# 3. Pre-aggregate clicks to shrink the hot key before any join
clicks_pre = (spark.table("web.clicks")
.groupBy("user_id", "url")
.agg(F.count("*").alias("hits"))) # collapses many rows per user
Step-by-step explanation.
clicks by (user_id, salt) into 1024 partitions spreads the hot user_id = 0 across partitions at read time, so the fat partition never forms before the join. This is salting applied as a repartition rather than as a join rewrite.user_features by user_id writes the table pre-shuffled into 1024 buckets. A later join of another bucketed table on user_id reads matching buckets locally with no shuffle — but note a skewed user_id still lands unevenly across buckets, so bucketing helps most when the join key is reasonably distributed.clicks (grouping by (user_id, url) and counting) collapses many raw rows per user into one row per (user_id, url). If the downstream join only needs per-user hit counts, the hot key's row count drops by orders of magnitude before the join, dissolving the skew at the source.Output.
| Technique | Skew formed? | Reused across jobs? |
|---|---|---|
| repartition (key, salt) | no (spread at read) | per-job |
| bucketBy(1024, user_id) | reduced (if key balanced) | yes (persisted) |
| pre-aggregate | no (fewer hot rows) | depends on query |
Rule of thumb. Prevent skew rather than react to it: repartition on (key, salt) for a one-off job, bucket tables on a well-distributed join key for repeated joins, and pre-aggregate to shrink a hot key before it reaches a join. Prevention fixes every downstream query at once.
Detailed explanation. AQE is not a universal skew solvent. It covers sort-merge join skew but leaves three common cases untouched: skewed groupBy aggregation, skewed window functions, and skew that causes an upstream OOM before AQE's runtime stats even apply. Knowing the gaps tells you when to fall back to salting.
PARTITION BY hot_key in a window concentrates one partition — repartition or pre-filter.Question. For three skewed queries, decide whether AQE handles it or you must salt.
Input.
| Query | Shuffle type | AQE handles? |
|---|---|---|
fact JOIN dim ON key (SMJ) |
join | yes |
groupBy(key).sum() |
aggregation | no |
row_number() OVER (PARTITION BY key) |
window | no |
Code.
from pyspark.sql import functions as F, Window
# 1. Join skew -> AQE handles it (config only, section 5 example above)
j = fact.join(dim, on="key") # AQE splits the fat join partition
# 2. groupBy skew -> AQE does NOT help; salt it (two-stage, section 4)
N = 64
g = (df.withColumn("salt", (F.rand()*N).cast("int"))
.groupBy("key", "salt").agg(F.sum("v").alias("p"))
.groupBy("key").agg(F.sum("p").alias("total")))
# 3. Window skew -> AQE does NOT help; reduce the partition or pre-filter
w = Window.partitionBy("key").orderBy("ts")
# If 'key' is hot, PARTITION BY key concentrates one task. Mitigations:
# - pre-filter the sentinel if it doesn't need ranking
# - narrow the window (add a sub-key to PARTITION BY where semantics allow)
ranked = df.filter(F.col("key") != 0).withColumn("rn", F.row_number().over(w))
Step-by-step explanation.
groupBy case is outside AQE. The aggregation shuffle concentrates the hot group on one reducer and AQE does not split aggregation partitions, so you apply the two-stage salted aggregation from section 4.PARTITION BY hot_key sends the entire hot partition to one task for ordering/ranking, and AQE's skew-join logic doesn't touch window shuffles. Mitigate by pre-filtering the sentinel (if it needs no ranking) or by narrowing the PARTITION BY with an extra sub-key where the ranking semantics allow.explode of a giant array attached to the hot key) blows memory before the shuffle, AQE's runtime stats never get a chance — you must fix the map-side logic (filter, limit, or restructure) rather than expecting any shuffle-time fix.Output.
| Query | Fix |
|---|---|
| SMJ join | AQE skew join (config only) |
| groupBy | two-stage salted aggregation |
window PARTITION BY hot
|
repartition / pre-filter sentinel |
| pre-shuffle OOM | fix map-side logic |
Rule of thumb. AQE fixes sort-merge join skew and nothing else. For skewed groupBy salt it, for skewed windows repartition or pre-filter, and for a map-side OOM fix the logic — "just enable AQE" only answers the join case.
A senior interviewer might ask: "Your team enabled Spark AQE and some skewed jobs got faster, but a nightly aggregation and a windowed dedup are still stuck on one task. Explain what AQE actually did for the jobs that improved, why the aggregation and window jobs didn't benefit, and design a prevention-first plan — repartition, bucketing, pre-aggregation, sentinel filtering — so skew stops recurring across the whole pipeline."
# 1. AQE handles the JOIN-skew jobs with zero code change
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256m")
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128m")
from pyspark.sql import functions as F, Window
# 2. The nightly aggregation was NOT helped by AQE -> two-stage salt it
N = 128
agg = (spark.table("fin.orders")
.withColumn("salt", (F.rand() * N).cast("int"))
.groupBy("country", "salt").agg(F.sum("revenue").alias("p"))
.groupBy("country").agg(F.sum("p").alias("total_revenue")))
# 3. The windowed dedup was NOT helped by AQE -> pre-filter sentinel + repartition
w = Window.partitionBy("user_id").orderBy(F.desc("ts"))
dedup = (spark.table("web.events")
.filter(F.col("user_id") != 0) # drop guest sentinel first
.repartition(1024, "user_id")
.withColumn("rn", F.row_number().over(w))
.filter(F.col("rn") == 1))
# 4. Prevention: bucket the hot dimension once so repeated joins skip the shuffle
(spark.table("web.user_features")
.write.mode("overwrite")
.bucketBy(1024, "user_id").sortBy("user_id")
.saveAsTable("web.user_features_bucketed"))
Step-by-step trace.
| Job | AQE helped? | Applied fix |
|---|---|---|
| fact ⋈ dim (SMJ) | yes | AQE skew-join split (config) |
nightly groupBy(country)
|
no | two-stage salted aggregation |
windowed dedup PARTITION BY user_id
|
no | sentinel filter + repartition |
| repeated dim joins | n/a | bucketBy(1024, user_id) |
groupBy so the dominant country spreads across 128 sub-groups then combines.user_id = 0) is filtered out first (it needs no per-user dedup), and the remainder is repartitioned by user_id so the window's PARTITION BY lands on balanced tasks.user_features on user_id persists a pre-shuffled layout so every future join on user_id skips the shuffle entirely — prevention that pays back across the whole pipeline.Output:
| Job | Before | After |
|---|---|---|
| SMJ join | 1 task, hours | AQE-split, minutes |
| nightly aggregation | spills, hours | two-stage salt, minutes |
| windowed dedup | 1 task, hours | filtered + repartitioned, minutes |
| repeated dim joins | shuffle each run | bucketed, shuffle-free |
Why this works — concept by concept:
(key, salt) in stage 1 and combined in stage 2. This is the owner-supplied fix AQE cannot provide.user_id = 0 carries no per-user meaning for the dedup, so filtering it before the window removes the hot partition at the source — the cheapest possible fix when the hot key is semantically irrelevant.Optimization
Topic — optimization
Optimization problems on AQE and skew prevention
Data processing
Topic — data-processing
Data-processing problems on repartition and bucketing
straggler) runs far longer than its peers; the stage's wall clock equals the slowest task, not the average. Adding executors never helps — reshape the key distribution instead.skew_factor = max(count) / (total_rows / distinct_keys) via SELECT key, COUNT(*) FROM t GROUP BY key ORDER BY 2 DESC. Any single key above ~5% of rows will skew a 200-partition shuffle; the hottest key almost always has a business meaning (sentinel 0/-1/NULL, default tenant, bot, guest).spark.speculation). Never fix skew with speculation — the speculative copy inherits the same fat partition.broadcast(dim) or raise autoBroadcastJoinThreshold) — no shuffle, no skew. (2) If both sides are large, salt. (3) If it's a sort-merge join, let AQE split it. (4) Filter the sentinel if it carries no join value.salt = rand()%N), explode the other side ×N (crossJoin(range(N))), join on (key, salt), drop the salt. Forgetting to explode the other side silently drops rows — the number-one salting bug.N = ceil(hot_key_rows / target_partition_rows), then verify each salted sub-key ≤ your biggest cold key. Typical N is 8-256; a sentinel mega-key sometimes needs the low thousands. Round to a power of two for convenience.N × hot_key_count, not N × all_keys).groupBy(key, salt).agg(partial); stage 2: groupBy(key).agg(combine partials). Works for algebraic aggregates (SUM, COUNT, MIN, MAX, AVG as sum/count). AQE does not fix aggregation skew — you own it.approx_count_distinct (HLL sketches merge correctly, ~2% error) or a two-stage DISTINCT (key, value) then COUNT for exactness.reduceByKey/aggregateByKey (map-side combine) over groupByKey (shuffles raw values). Under skew that's a working job versus an OOM on the hot key's reducer.spark.sql.adaptive.enabled=true, spark.sql.adaptive.skewJoin.enabled=true; a partition is split only if it exceeds both skewedPartitionFactor × median (default 5) and skewedPartitionThresholdInBytes (default 256 MB). Applies to sort-merge joins only.(key, salt), bucket tables on a well-distributed join key (bucketBy), pre-aggregate to shrink a hot key before a join, and filter sentinels that carry no join value. Prevention fixes every downstream query at once.Data skew is an uneven distribution of rows across the partitions of a distributed job, so one partition holds far more data than the others and the task processing it — the straggler — runs much longer than its peers. Because a distributed stage cannot finish until its slowest task finishes, the whole job's wall-clock time collapses onto that one overloaded task while the rest of the cluster sits idle. Skew is a property of the data (a hot key, a sentinel NULL, a dominant region), not of the hardware, which is why the fix is to reshape the key distribution — broadcast, salting, AQE, or pre-aggregation — rather than to add more executors.
Because a shuffle routes every row with the same key to the same partition on the same task, so if one key owns most of the rows, one task receives most of the rows. That task has to sort, join, or aggregate a partition that is tens or hundreds of times larger than the median, so it exceeds executor memory, spills sorted runs to disk, and grinds for minutes or hours while the other 199 tasks finished their thin partitions in seconds. This is why the job appears "stuck at 99%": 199 of 200 tasks are done and the stage is waiting on the single fat one. Retrying does not help — the retry gets the identical fat partition — so the fix is always to reshape the key (broadcast the small side, salt the hot key, or let Spark AQE split the partition).
Salting appends a small random integer suffix (0..N-1) to a skewed key so that one hot key becomes N synthetic sub-keys that hash to N different partitions, spreading the straggler's work across N tasks. You use it when a join or groupBy has a hot key and you cannot broadcast the other side because both are large. For a join you must also replicate ("explode") the other side across all N salt values so every salted row still finds its match, then join on the composite (key, salt) and drop the salt; forgetting to explode the other side silently drops rows. Size N as ceil(hot_key_rows / target_partition_rows) and prefer targeted salting — salt only the census-named hot keys and leave the cold keys with a fixed salt of 0 — so the dimension barely grows.
No. Spark AQE (Adaptive Query Execution) detects an oversized shuffle partition at runtime and automatically splits it into balanced sub-partitions, but only for sort-merge joins (and shuffled-hash joins in recent versions). It does nothing for skewed groupBy/aggregation shuffles, skewed window functions (PARTITION BY hot_key), or a map-side out-of-memory error that happens before the shuffle. So "enable AQE" is a correct answer only for join skew; a skewed aggregation still needs a two-stage salted groupBy, a skewed window needs a repartition or a sentinel pre-filter, and a map-side OOM needs a logic fix. A partition is also only split if it exceeds both skewedPartitionFactor × median and skewedPartitionThresholdInBytes, so a genuinely skewed but small partition may be left alone.
Use a two-stage salted aggregation, and only for algebraic aggregates. Stage one adds salt = rand()%N and groups by (key, salt) so the dominant group splits into N partial groups on N tasks; stage two groups by the original key and combines the partials — SUM of partial sums, SUM of partial counts, MIN/MAX of partials, and AVG as sum(sums)/sum(counts). This works because those aggregates are algebraic (combinable from partials). COUNT DISTINCT is holistic and cannot be combined from partials, so use approx_count_distinct (HyperLogLog sketches merge correctly, ~2% error) or a two-stage DISTINCT (key, value) then COUNT when you need an exact answer. In RDD code, always prefer reduceByKey/aggregateByKey over groupByKey so values combine map-side before the shuffle.
Run a hot-key census on the shuffle key — SELECT key, COUNT(*) AS n, 100.0*COUNT(*)/SUM(COUNT(*)) OVER () AS pct FROM t GROUP BY key ORDER BY n DESC LIMIT 10 — and compute the skew factor max(n) / (total / distinct_keys). Any single key above ~5% of rows will skew a typical 200-partition shuffle, and the top of the list usually names the culprit (a sentinel 0/-1/NULL, a default account, a bot, guest traffic). After a job runs, confirm skew in the Spark UI by sorting the stage's tasks by duration and dividing the max shuffle-read size by the median — over ~10x means one fat partition, and the straggler will show disk spill while every other task shows none. Wiring the census into CI (fail the build if any key exceeds a share threshold on a new large table) stops skewed joins from ever reaching production.
Docs explain the concept. PipeCode drills explain the decision — when to broadcast versus salt, why AQE fixes joins but not group-bys, how to size the salt factor, and how to spot the straggler in the Spark UI before it becomes a 3 AM page. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the production trade-offs data engineers actually face.
Practice optimization problems →
Practice data-processing problems →