PySpark / Performance Fig_08  ·  Lesson 08 / 14
Fig_08 · Data skew · v1.0 OSS Spark + Azure Databricks

Data skew

Salting · SQL hints · taming the one hot key

A shuffle splits a stage into one task per partition. When one key holds most of the rows, one partition swells, one task runs long after the rest have finished, and the whole stage waits on it. Salting breaks the hot key into pieces; SQL hints steer the planner by hand.

Spark 3.2+ / DBR LTS AQE skew first defence factor 5× · 256 MB verified Jun 2026

01 · What it is

One key too many, one task too slow

Data skew is rows spread unevenly across keys. After a shuffle — Spark moving rows across the network so every row with the same key lands in the same partition — a few "hot" keys pile into a few huge partitions while the rest stay tiny. The task that processes a huge partition runs for minutes after every other task has finished: a straggler. And a stage is only as fast as its slowest task.

analogy Skew is a supermarket where everyone happens to need the one cashier who handles loyalty cards — nine lanes empty, one lane 200 people deep. A bigger till for that lane doesn't help; opening more lanes for those customers does. That is the whole idea behind the two manual cures below.

There are exactly two levers here, and both come after the automatic one (AQE skew join, Lesson 05) has done what it can. The deep dive explains each once; for now, just hold the shape:

The one rule to remember Skew is a data problem, not a memory problem. A bigger executor for one giant partition just delays the OOM. The fix is to break the hot key into many smaller pieces so the work spreads across tasks.
What skew is

Rows spread unevenly across keys, so after a shuffle a few partitions swell while the rest stay tiny.

Balanced partitions

Rows spread evenly — every task does about the same work, and Max task time tracks the Median.

One hot key

One key owns most rows: its partition becomes a straggler that spills and runs long after the rest finish.

02 · Why it matters

One straggler holds the whole cluster hostage

03 · How it works — deep dive

Spot it, then split the hot key

Every skew fix rests on one mechanic: the shuffle picks a row's partition from hash(key) % numPartitions, so equal keys collide into the same partition. If one key dominates, its partition dominates. Everything below follows from that. Each idea is explained here once — later sections show it, verify it, and help you choose, but don't re-explain it.

3.1 · First, see the skew in the Spark UI

Before any fix, confirm the straggler. Skew has an unmistakable signature:

3.2 · Salting — the mechanism

use case A clickstream where ~90% of rows are country = 'US'. Group-by-country sends nearly all rows to one partition; salting splits US across 16 partitions.

3.3 · Salting an aggregation — two-stage group-by

analogy Count a stadium by having each section count itself first (stage 1), then add the 50 section totals (stage 2) — instead of one person counting everyone.

3.4 · Salting a join — salt one side, explode the other

use case A 5 TB transactions fact (skewed on merchant_id, one mega-merchant = 40% of rows) ⋈ a small merchants dimension.

3.5 · SQL hints — the planner override

Hints are /*+ … */ comments placed immediately after SELECT that instruct the planner. Two families, each explained once here:

HintWhat it forcesNote
BROADCAST(t)Broadcast t to every executor (map-side join)Highest join priority; aliases BROADCASTJOIN, MAPJOIN
MERGE(t)Sort-merge joinBelow BROADCAST; aliases SHUFFLE_MERGE, MERGEJOIN
SHUFFLE_HASH(t)Shuffle-hash joinBelow MERGE in priority
SHUFFLE_REPLICATE_NL(t)Replicated nested-loop joinLowest join priority; risks a Cartesian blow-up
COALESCE(n)Reduce to n partitionsNo shuffle — merges existing partitions only
REPARTITION(n | col)Full shuffle to n / by colAlso REPARTITION_BY_RANGE([n,] col)
REBALANCE([n][,col])Even out partition sizesSilently ignored unless AQE is enabled

Join-strategy priority: BROADCAST > MERGE > SHUFFLE_HASH > SHUFFLE_REPLICATE_NL.

3.6 · The order of operations (Rung 3)

Skew fix ≠ size fix Salting helps skew, not raw size. A genuinely huge but even join still needs the full shuffle (or bucketing, Lesson 11). Salting an unskewed job only adds overhead. Confirm Max ≫ Median first — otherwise you are paying for nothing.

Figure A · flatten the hot key

Watch the tall bar come down

One key (US, in red hatch) towers over the rest: a classic straggler. Drag the salt N slider to split that hot key across N partitions and watch the bars rebalance — and watch the join's small side get exploded ×N so every salted row still finds its match. This figure isolates one thing: the row distribution. Where it runs across the nodes is Figure B.

salt(N) → partition balance

A groupBy(country) / join key where US is ~90% of rows. Salt splits it across partitions.

Shuffle partitions — bar height = rows in that partition (= that task's work)
—
busiest task (rows)
—
max ÷ median
—
dimension blow-up
Join side: the small dimension exploded into one copy per salt value

Simplified illustration — real partition counts and hashing vary; the point is how salt flattens a hot key and what it costs the joined side.

04 · The code — and how to verify it

Five moves, each with its proof

Every technique below is paired with how to prove it worked — the .explain() plan node or the Spark-UI signal. Apply, then verify; never assume.

Simple reading rule When you see code, ask one question first: "Am I fixing the skew at the source (salt / AQE split the hot key), or just adding memory?" A bigger executor only delays the straggler; splitting the hot key is what actually removes it.

Step 0 — confirm AQE skew join is on (before salting)

python# AQE skew join is the FIRST line of defence — it splits skewed partitions with no
# code change. Confirm it's on before reaching for manual salting.
spark.conf.get("spark.sql.adaptive.enabled")            # 'true' (default since Spark 3.2)
spark.conf.get("spark.sql.adaptive.skewJoin.enabled")   # 'true' (default)

# A partition is "skewed" if its size > skewedPartitionFactor (5.0) × median AND
# > skewedPartitionThresholdInBytes (256 MB); AQE then SPLITS it into sub-partitions.

# VERIFY — Spark UI SQL tab: the join's AQEShuffleRead node shows skewed=true, and the
# straggler's Max task time drops toward the Median after AQE splits it.

Salt a skewed aggregation (two-stage group-by)

pythonfrom pyspark.sql import functions as F

N = 16  # salt buckets — split each hot key across up to 16 partitions

# clicks is skewed: ~90% of rows are country = 'US'. A plain groupBy("country")
# sends nearly all rows to ONE partition -> one straggler task.

# Stage 1: partial aggregate on the SALTED key (key, salt). rand() spreads the hot
# key's rows across N buckets -> N partitions -> N tasks share the work.
salted = (clicks
    .withColumn("salt", (F.rand() * N).cast("int"))     # salt in 0..N-1
    .groupBy("country", "salt")
    .agg(F.sum("revenue").alias("partial_rev")))        # sum is re-aggregatable

# Stage 2: drop the salt, re-aggregate the N partials by the REAL key.
result = (salted
    .groupBy("country")
    .agg(F.sum("partial_rev").alias("revenue")))

# VERIFY — result.explain(mode="formatted") shows TWO HashAggregate/Exchange pairs
# (partial then final). Spark UI Stages tab: the partial-agg stage's Max task time is
# now close to its Median — the single straggler is gone.
result.explain(mode="formatted")

Composability note: use sum/count/min/max for the partial. For an average, carry sum and count partials and divide at the very end — averaging the stage-1 averages is wrong.

Salt a skewed join (build the salt key + explode the dimension)

pythonfrom pyspark.sql import functions as F

N = 16

# tx is skewed on merchant_id (one mega-merchant = 40%); merchants is small/unskewed.

# 1) Build the salt key on the SKEWED (large) side: append a random 0..N-1.
tx_salted = tx.withColumn("salt", (F.rand() * N).cast("int"))

# 2) EXPLODE the other side into N copies — one per salt value — so every salted
#    left row finds its matching right copy. explode(array(0..N-1)) = N rows per key.
merchants_exploded = (merchants
    .withColumn("salt", F.explode(F.array([F.lit(i) for i in range(N)]))))

# 3) Join on the COMBINED (merchant_id, salt) key.
joined = tx_salted.join(merchants_exploded, on=["merchant_id", "salt"], how="inner")

# VERIFY — joined.explain(mode="formatted") still shows a SortMergeJoin, but on
# (merchant_id, salt). Spark UI Stages tab: the join stage's Max task time now tracks
# the Median — the hot-key straggler is split across N tasks. Cost: merchants now has
# N× the rows (acceptable only because it's small).
joined.explain(mode="formatted")

Salt ONLY the hot keys (keep the blow-up proportional)

pythonfrom pyspark.sql import functions as F

N = 16
hot = ["MERCHANT_MEGA"]   # the few keys you confirmed are skewed (from the UI)

# Skewed keys get a real 0..N-1 salt; everyone else gets a constant 0 (no explosion).
tx_salted = tx.withColumn(
    "salt",
    F.when(F.col("merchant_id").isin(hot), (F.rand() * N).cast("int")).otherwise(F.lit(0)))

# Explode the other side ×N ONLY for hot keys; non-hot rows keep a single salt=0 copy.
m_hot  = (merchants.filter(F.col("merchant_id").isin(hot))
    .withColumn("salt", F.explode(F.array([F.lit(i) for i in range(N)]))))
m_cold = merchants.filter(~F.col("merchant_id").isin(hot)).withColumn("salt", F.lit(0))
merchants_salted = m_hot.unionByName(m_cold)

joined = tx_salted.join(merchants_salted, on=["merchant_id", "salt"], how="inner")

# VERIFY — same balanced task times, but merchants only grew N× for the few hot keys,
# not the whole dimension. Check Stages tab Max vs Median again.

Join-strategy & partitioning hints (DataFrame API + SQL)

pythonfrom pyspark.sql.functions import broadcast

# DataFrame-API equivalents of the SQL hints:
tx.join(broadcast(merchants), "merchant_id")             # == /*+ BROADCAST(merchants) */
tx.hint("merge").join(merchants, "merchant_id")         # == /*+ MERGE(...) */  force sort-merge
tx.hint("shuffle_hash").join(merchants, "merchant_id")  # == /*+ SHUFFLE_HASH(...) */
tx.repartition(200, "merchant_id")                     # == /*+ REPARTITION(200, merchant_id) */
tx.coalesce(50)                                       # == /*+ COALESCE(50) */

# VERIFY — .explain() shows the forced node (BroadcastHashJoin / SortMergeJoin /
# ShuffledHashJoin) or the Exchange with the new partition count / partitioning.

Equivalent Spark SQL — hints go immediately after SELECT:

sql-- Join-strategy hint (priority BROADCAST > MERGE > SHUFFLE_HASH > SHUFFLE_REPLICATE_NL).
SELECT /*+ BROADCAST(m) */ t.*, m.merchant_name
FROM   catalog.schema.transactions t
JOIN   catalog.schema.merchants m ON t.merchant_id = m.merchant_id;

-- Partitioning hints: repartition (full shuffle) or coalesce (no shuffle).
SELECT /*+ REPARTITION(200, customer_id) */ * FROM catalog.schema.transactions;
SELECT /*+ COALESCE(50) */                  * FROM catalog.schema.transactions;

-- REBALANCE evens out partition sizes — but is IGNORED unless AQE is enabled.
SELECT /*+ REBALANCE(merchant_id) */ * FROM catalog.schema.transactions;

Figure B · where the hot key lands

See it run across the cluster

This figure isolates a different thing from Figure A: not the distribution, but where the work physically lands. Step a skewed join across 1 driver + 3 executors and watch the hot key US pile onto one executor as a straggler — then flip to salted and watch it spread across all three. Use Prev / Play / Next.

skewed join vs salted join across the nodes

clicks (skewed on country, US = most rows) ⋈ dim. Watch one task straggle, then rebalance after salting.

Driver plan · schedule

Executor 1 1 core

Executor 2 1 core

Executor 3 1 core

step 1 / 4
PySpark — dummy code
Shuffle partitions (by key)

Simplified illustration — real partition counts, hashing, and task timings vary. The point is how the hot key stragglers one task, and how salting spreads it.

05 · Decision table

Which lever, and what it costs

Read this by the symptom you're facing — it answers "which tool?" at a glance. (The why is §3; this is the which.)

LeverWhat it fixesCode changeCostWhen to reach for it
AQE skew joinSkewed join partitionsNone (automatic)A little bookkeepingFirst — always, before salting
Salt an aggregationSkewed groupByTwo-stage group-byOne extra shuffleSkewed agg AQE can't help
Salt a joinSkewed join keySalt + explode + combined keyN× blow-up of exploded sideSkew AQE's split can't resolve
Join-strategy hintWrong join strategy / stale stats/*+ … */ or .hint()Wrong hint worse than noneYou know the estimate is wrong
Partitioning hintBad partition layout/*+ … */ or APIA shuffle (except COALESCE)Known-bad layout before a wide op

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of §3:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
Salting before checking the UISalting an unskewed job just adds a shuffle for nothing.Confirm Max ≫ Median first, then salt.
Forgetting the second stageA salted groupBy("key","salt") alone gives N rows per key — a wrong answer, not just slow.Add the final groupBy("key") to drop the salt.
Averaging the averagesavg of stage-1 averages is not the true average.Carry sum+count partials and divide at the end.
Salting one side of a join onlySalted rows find no match and inner-join silently loses rows — a correctness bug.Explode the other side ×N so every salt value matches.
Exploding the entire dimension ×NThe N× blow-up is wasted on the 99% of keys that aren't skewed.Salt only confirmed hot keys; give the rest salt=0.
Expecting REBALANCE with AQE offThe hint is silently ignored — the "fix" does nothing.Enable AQE, or use REPARTITION.
Confusing a partitioning hint with a join hintReshaping data won't change the join strategy, and vice versa.REPARTITION/COALESCE/REBALANCE reshape; BROADCAST/MERGE/SHUFFLE_HASH pick a join.

08 · References

Sources