Spark Data Skew
Causes, Salting Joins and Skewed Join Fixes
Spark data skew is a join or grouping key so uneven that 1 task gets most of the rows, and the stage lasts as long as that task. To fix a skewed join, let adaptive query execution split the partition, broadcast the small side, then salt or isolate the hot keys.
What is data skew in Spark, and how do you fix a skewed join?
Data skew means a few keys own most of the rows. Spark shuffles rows by key, so every row of a hot key lands in the same partition and one task does the work that 100 tasks should share. The stage ends when that task ends, and the rest of the cluster waits.
Fix a skewed join in this order. First confirm adaptive query execution is splitting the oversized partitions, since it needs no code. Then broadcast the small side if it fits in memory. Then salt the hot keys, or isolate them and join them separately. Each step costs more effort than the one before, and most jobs stop at the first or second.
Skew comes from the data, not from Spark. Typical hot keys are a default value such as guest or unknown, a null, a test account that logs millions of events, or one very large customer. A key that was fine in January can be hot by March, which is why a job that has run for months suddenly slows down.
Know Spark data skew the way the interviewer who asks it knows it.
How one hot key becomes one slow task
Take an events table of 100,000,000 rows joined to customers on customer_id. One key, guest, holds 40% of the rows, which is 40,000,000. Spark's default is 200 shuffle partitions (spark.sql.shuffle.partitions). The other 60,000,000 rows spread over the other 199 partitions, about 301,508 rows each.
At 100 bytes a row the hot partition is 4,000 MB and a typical one is 30 MB, a ratio of 133 to 1. The 199 ordinary tasks finish in seconds and the stage waits on the hot one. That task also holds the most data in memory, so skew is a common cause of the executor failures in Spark out of memory errors.


Salting the hot key into 64 sub-keys cuts the biggest task to 625,000 rows, 64 times smaller. Adding more executors doesn't help the original job, because the hot key is 1 task whatever the cluster size.
How to tell if a Spark job is skewed
The symptom is a stage that sits at 199 of 200 tasks for most of its run. Open that stage in the Spark UI. The stage detail page shows summary metrics for all tasks, including duration and shuffle read size with records, and a table with the same figures per task. In an even stage the maximum is close to the median. In a skewed stage the maximum is many times the median, and the slow task is the one with the largest shuffle read.
Adaptive query execution uses the same comparison. Its own definition of a skewed partition is one larger than 5 times the median partition size and larger than 256 MB. In the example the median is 30 MB, so 5 times it is 150 MB, and the 4,000 MB partition clears both bars. A good field check is to apply that rule by eye: more than 5 times the median is worth a look.
To name the key, count rows per join key on each side and sort descending. One key far above the rest is the culprit. Check both sides, because the fix differs when the hot key is also large in the table you hoped to broadcast.
Find the hot keys with a group by count
from pyspark.sql import functions as F
# Rows per join key, biggest first. A key far above the rest is the hot key.
(events
.groupBy("customer_id")
.count()
.orderBy(F.desc("count"))
.show(10))Skewed join fix 1: let adaptive query execution split the partition
Adaptive query execution (AQE) re-plans a query from the real sizes of the shuffle output. Its skew join optimization splits, and replicates if needed, the oversized partitions of a sort-merge join into roughly even tasks. It runs when both spark.sql.adaptive.enabled and spark.sql.adaptive.skewJoin.enabled are true. The Spark documentation lists AQE as on by default since 3.2.0 and the skew setting as true since 3.0.0, so on a current release a skewed join may already be handled.
In the example, AQE sees one partition of 4,000 MB against a 30 MB median, and splits it toward the 64 MB advisory size. That's about 60 tasks. Each task reads its slice of the big side and the same matching partition of the small side, which is the replication the documentation mentions.
The limits matter more than the defaults. AQE changes join shuffles, so a skewed group by or window function is not covered. It can only split a side whose rows are safe to repeat: the preserved side of an outer join cannot be split, because every copy would emit its unmatched rows again, and a full outer join can split neither side. If splitting would force an extra shuffle somewhere else in the plan, the rewrite may be dropped. Read the final plan in the SQL tab of the Spark UI to see whether the join was split, instead of assuming it was.
If AQE fires but the stage is still slow, the usual cause is a hot key too large for the splitting to help, or a hot key present on both sides, where the join output for that key is the product of its row counts and no split makes that product small.
Adaptive skew join settings and their defaults
spark.sql.adaptive.enabledspark.sql.adaptive.skewJoin.enabledspark.sql.adaptive.skewJoin.skewedPartitionFactorspark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytesspark.sql.adaptive.advisoryPartitionSizeInBytesspark.sql.shuffle.partitionsSkewed join fix 2: broadcast the small side
A broadcast hash join copies the small table to every executor and joins each big-table row where it already sits, so the big table is never shuffled and its hot key has no partition to pile into. Spark broadcasts on its own below spark.sql.autoBroadcastJoinThreshold, which the Spark tuning guide gives as 10485760 bytes, 10 MB, and -1 turns it off. A BROADCAST hint forces the choice even above the threshold.
In the example, customers has 2,000,000 rows. At 100 bytes a row that is 200 MB, well above the 10 MB default but small for an executor, so a hint makes the skew disappear. The hint is a request: the same guide says Spark doesn't guarantee it will use the strategy named, since not every strategy supports every join type.
The limit is memory. The table is built on the driver and held by every executor, so a broadcast of something that does not fit fails the job with an out of memory error.
Salting a join in Spark: how it works and how many buckets to use
Salting gives the hot key a second part. On the big side, each row of a hot key gets a random bucket number from 0 to N minus 1. On the small side, each row of a hot key is copied N times, once per bucket. The join then runs on the key and the bucket together, so the hot key's rows hash to as many partitions as it has buckets, and every big row still finds exactly one matching small row.
Choose N from the sizes. The hot key holds 4,000 MB and a comfortable task is the 64 MB advisory size, so 4,000 / 64 is about 63, and 64 is the round number. Each bucket then carries 625,000 rows, 62.5 MB. The 64 sub-keys hash independently across the 200 shuffle partitions, so some can share a partition and the spread is close to 64, not exactly.
Salt only the keys that are hot. Salting every key multiplies the small side by N: 2,000,000 customer rows become 128,000,000, more than the 100,000,000 events they're joined to. Salting only the hot key leaves 2,000,000 rows and adds 63 copies of the one hot row. The price is that you must know the hot keys, and a new hot key next month needs a new entry in the list.
Salting leaves the total work of the join the same and spreads it across more tasks. If the small side fits in memory, a broadcast is cheaper than any salt. Salt when the small side is too big to broadcast and AQE can't split the join, for example because the join is an outer join on the skewed side.
A salted join and a salted aggregation in PySpark and Spark SQL
from pyspark.sql import functions as F
SALT = 64
HOT = ["guest"] # the hot keys found with the count above
# Big side: a random bucket for hot keys, bucket 0 for every other key.
big = events.withColumn(
"salt",
F.when(F.col("customer_id").isin(HOT),
F.floor(F.rand() * SALT).cast("int"))
.otherwise(F.lit(0)),
)
# Small side: 64 copies of a hot key, 1 copy of every other key.
small = customers.withColumn(
"salt",
F.explode(
F.when(F.col("customer_id").isin(HOT),
F.sequence(F.lit(0), F.lit(SALT - 1)))
.otherwise(F.array(F.lit(0)))
),
)
joined = big.join(small, ["customer_id", "salt"]).drop("salt")SELECT e.*, c.segment
FROM (
SELECT *,
CASE WHEN customer_id IN ('guest')
THEN CAST(floor(rand() * 64) AS INT)
ELSE 0 END AS salt
FROM events
) e
JOIN (
SELECT c.*,
explode(CASE WHEN customer_id IN ('guest')
THEN sequence(0, 63)
ELSE array(0) END) AS salt
FROM customers c
) c
ON e.customer_id = c.customer_id AND e.salt = c.saltfrom pyspark.sql import functions as F
SALT = 64
# Stage 1: spread each key over 64 sub-keys and aggregate those.
partial = (events
.withColumn("salt", F.floor(F.rand() * SALT).cast("int"))
.groupBy("customer_id", "salt")
.agg(F.count("*").alias("n")))
# Stage 2: combine the sub-keys. Valid only when the stage 1 result can be
# combined, so a sum of partial counts works but a sum of distinct counts does not.
result = partial.groupBy("customer_id").agg(F.sum("n").alias("n"))Common salting mistakes that drop rows or add work
Match the random range to the exploded range. rand() returns values from 0 up to but not including 1, so floor(rand() * 64) gives 0 to 63, and the small side must carry exactly 0 to 63. A ceil on the big side produces 64 as a bucket that has no match, and an inner join silently drops those rows with no error.
Keep the types equal. A salt that is a long on one side and an int on the other can force a cast that stops the optimizer from using the key as a clean join condition. Cast both sides to the same type and drop the salt column after the join.
Do not salt a key that is skewed on both sides. The join output for a key is the product of its row counts on the 2 sides, and salting one side leaves the other side's hot rows to be copied N times. Salt the larger side and look for a way to pre-aggregate the other.
Check the result, not only the runtime. Compare the row count and a checksum of the salted join with the unsalted one on a sample, since every salting mistake that matters changes the output.
Skewed join fix 4: split the hot keys out and join them separately
Isolation treats the hot keys as a different problem from everything else. Filter the big table into hot rows and the rest. Join the rest the normal way, where no partition is oversized now. Join the hot rows to just the matching rows of the small table with a broadcast, which is cheap because the hot keys are few. Union the 2 results. In the example the hot side is 40,000,000 rows against 1 customer row.
This is the fix for the case where the small side is too big to broadcast as a whole but its hot slice is tiny, and it avoids salting's copies and its random numbers. It needs the same list of hot keys, and it costs 2 scans of the big table unless you cache it.
Null keys are the easiest hot key to isolate. A null never equals anything in an equi-join, so null rows match nothing, but they still hash to the same partition. An inner join discards them, so filter them out before the shuffle. An outer join keeps them, so pull them out, give them their unmatched columns directly, and union them back.
Skew in a group by, a window or a write
A hot key hurts a group by less than a join, because Spark aggregates within each task before the shuffle. A count of 40 million rows for 1 key crosses the network as a few partial counts, one per task, and not 40 million rows. Skew bites a group by when the aggregate cannot be combined that way, such as a distinct count, a collected list or a percentile, and in window functions, where all rows of a partition key must be sorted together in 1 task.
The fix for an aggregate that can be combined in 2 steps is a salted 2 stage aggregation: group by the key and a salt, then group again by the key alone. It works for a sum of counts and doesn't work for a sum of distinct counts, since the same value can appear under 2 salts. For a window with a hot partition key, add a salt to the partition key only if the calculation can be split, and otherwise reduce the data first. See PySpark groupBy for how the shuffle behaves.
Skew also shows up when writing. The REBALANCE hint, documented in the Spark SQL hints reference, splits skewed partitions on a best effort basis before an output is written and is ignored when AQE is off. There is no SKEW hint in that reference, so a skew hint seen in a blog post belongs to some other system.
Skewed join fixes compared on code, knowledge and cost
- Code change
- Hot keys known first
- Small side fits memory
- Small side grows
- Code changeNone, config only
- Hot keys known firstNo
- Small side fits memoryNo
- Small side growsNo
- Code changeHint or threshold
- Hot keys known firstNo
- Small side fits memoryYes
- Small side growsNo
- Code changeRewrite both sides
- Hot keys known firstHot keys only
- Small side fits memoryNo
- Small side growsN copies of hot keys
- Code changeSplit and union
- Hot keys known firstYes
- Small side fits memoryHot slice only
- Small side growsNo
How skew comes up in a Spark interview
The question is usually phrased as a symptom: a job stuck on its last task, a join that runs for hours on a table with a popular key, or an out of memory error on 1 executor. A strong answer names the cause in terms of partitions and tasks, says how you would confirm it in the Spark UI, then walks the fixes in order of effort and says what each one costs.
Expect follow-ups on what you would do if the broadcast doesn't fit, how you would pick the salt count, and why salting breaks a distinct count. The PySpark interview questions and Spark optimization interview questions include this ground, the PySpark practice problems let you write the join, and a Spark mock interview lets you practice explaining it out loud.
Spark data skew FAQ
What is data skew in Spark?+
How do you fix a skewed join in Spark?+
What is salting in Spark?+
How many salt buckets should I use?+
Does Spark 3 handle skew automatically?+
How do I see skew in the Spark UI?+
Does salting help a group by?+
Is a broadcast join better than salting?+
The candidate who gets the offer
- 01
Reading a solution is not the same as writing one
Every engineer who has frozen on a query they had read a dozen times knows the gap. The only preparation that closes it is producing the answer yourself, under time, before the interview does it for you
- 02
76% of hiring managers reject on the coding task, not the resume
From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice
- 03
Spark rounds are diagnosis rounds
Skew, shuffles, broadcast judgment, partition pruning, exactly-once streaming. Reading the task table and naming the failure class is the signal, not the API
Related guides
PySpark interview questions
The Spark questions data engineering interviews ask
Spark Streaming vs Structured Streaming
How the two Spark streaming APIs differ and which to use
PySpark practice problems
Write joins and aggregations before the loop
Spark optimization interview questions
Partitioning, shuffles and tuning questions
Spark mock interview
Explain a skewed join out loud under interview conditions
Spark out of memory error
Why one executor dies when a partition is too big
This week's community challenge
Dirty, production-shaped data, scored blind each week.