Home / Data Platform / Databricks & Spark

Data PlatformHow-toAdvanced

Spark data skew in depth: diagnose skewed joins and aggregations, then fix with AQE, broadcast or salting

Prove a slow Spark stage is caused by skew, not too few resources. Read the task-duration distribution, find the hot keys, and choose between AQE skew join, broadcast joins, salting, splitting hot keys and two-phase aggregation.

Deepak Bhardwaj4 min read

Applies toApache Spark 3.2+ (Adaptive Query Execution), Databricks Runtime, Synapse and Fabric Spark; config names are Spark's, platform defaults vary

A stage where 199 tasks finish in 20 seconds and one runs for 40 minutes is not a cluster-size problem. Adding nodes changes nothing; the one task still processes most of the data. That is skew.

Prove it in the Spark UI

Open the slow stage, Summary Metrics for Completed Tasks:

Metric Healthy Skewed
Duration max / median < 3x 10x-1000x
Shuffle Read Size max / median Similar One task reads GBs, others MBs
Spill (memory/disk) Little Concentrated in the long task

Also check the SQL tab: a SortMergeJoin or HashAggregate feeding the slow stage tells you which operation is skewed.

Find the hot keys

from pyspark.sql import functions as F

(orders.groupBy("customer_id").count()
       .orderBy(F.desc("count")).limit(20).show())

# share of rows held by the top key
total = orders.count()
orders.groupBy("customer_id").count().orderBy(F.desc("count")).limit(1) \
      .withColumn("share", F.col("count") / total).show()

Common culprits: NULL or empty-string keys, a default value like -1 or UNKNOWN, a single giant tenant, or a "guest" customer.

Tip

Check NULL keys first. In an inner join they never match anyway; filter them out before the join and the skew often disappears.

Fix 1: let AQE split skewed partitions

Spark 3's Adaptive Query Execution detects skewed shuffle partitions in sort-merge joins and splits them.

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", "256MB")

A partition is treated as skewed when it is larger than both the factor times the median and the byte threshold. Lower them if AQE is not kicking in. In the SQL plan you will see skew=true on the join or an AQEShuffleRead with skewed partitions split.

Limits: AQE skew handling works for sort-merge joins (not every join type or side, for example the outer side of some outer joins) and does not fix skewed aggregations.

Fix 2: broadcast the small side

If one side is small enough to fit in executor memory, avoid the shuffle entirely:

from pyspark.sql.functions import broadcast
result = orders.join(broadcast(customers), "customer_id")
SELECT /*+ BROADCAST(c) */ * FROM orders o JOIN customers c USING (customer_id);

Raise spark.sql.autoBroadcastJoinThreshold carefully; broadcasting a 2 GB table to every executor causes driver and executor OOMs.

Fix 3: salting

When both sides are large, spread the hot key over N sub-keys:

N = 16
orders_s = orders.withColumn("salt", (F.rand() * N).cast("int"))
customers_s = customers.crossJoin(spark.range(N).withColumnRenamed("id", "salt"))

joined = orders_s.join(customers_s, ["customer_id", "salt"]).drop("salt")

The big side gets a random salt; the other side is replicated N times so every salt finds a match. Cost: the replicated side grows N times, so salt only the hot keys if you can:

hot = [r.customer_id for r in orders.groupBy("customer_id").count()
       .filter("count > 10000000").collect()]

orders_s = orders.withColumn("salt",
    F.when(F.col("customer_id").isin(hot), (F.rand() * N).cast("int")).otherwise(F.lit(0)))
cust_hot = customers.filter(F.col("customer_id").isin(hot)) \
    .crossJoin(spark.range(N).withColumnRenamed("id", "salt"))
cust_rest = customers.filter(~F.col("customer_id").isin(hot)).withColumn("salt", F.lit(0))
customers_s = cust_hot.unionByName(cust_rest)

Fix 4: split the hot keys out

Process hot keys separately with a broadcast join and union the results:

hot_df = orders.filter(F.col("customer_id").isin(hot))
rest_df = orders.filter(~F.col("customer_id").isin(hot))

result = (rest_df.join(customers, "customer_id")
          .unionByName(hot_df.join(broadcast(customers.filter(F.col("customer_id").isin(hot))), "customer_id")))

Fix 5: two-phase aggregation for skewed groupBy

partial = (events.withColumn("salt", (F.rand() * 32).cast("int"))
           .groupBy("tenant_id", "salt").agg(F.sum("bytes").alias("bytes")))
final = partial.groupBy("tenant_id").agg(F.sum("bytes").alias("bytes"))

This works for associative aggregations (sum, count, min, max). countDistinct needs approx_count_distinct or a different approach.

Choosing

Situation Fix
NULL / default keys Filter or handle separately
One side small Broadcast
Sort-merge join, moderate skew AQE skew join
Both sides big, few hot keys Split hot keys or targeted salting
Skewed aggregation Two-phase aggregation
Skew from file layout on read Repartition after read, or fix the writer

Verify

Rerun and compare the stage's max/median duration and shuffle read. A good fix brings max close to 2-3x median. Check the total job time too: salting can make the skewed stage fast but add a costly cross join elsewhere.

Prevent it next time

  • Profile key distributions for new large joins before they reach production.
  • Keep AQE on (default in Spark 3.2+) and review the skew thresholds for big jobs.
  • Model data so default keys (-1, UNKNOWN) are not used as join keys.