top of page

Diagnosing Data Skew in PySpark for Azure Databricks AutoBroadcast Joins Salting and AQE

6 hours ago
9 min read

A PySpark job can look healthy for 95% of its runtime, then sit frozen on a handful of tasks while one executor keeps spilling, retrying, and finally dying with an out-of-memory error. That pattern is often not a cluster sizing problem. It is usually data skew.


In Azure Databricks, skew shows up most clearly during shuffle-heavy stages: joins, aggregations, window functions, `dropDuplicates`, and wide transformations. One or a few keys carry far more rows than the rest, so Spark assigns too much data to a small number of partitions. The result is uneven task duration, large shuffle reads, executor memory pressure, and sometimes stranded tasks that keep the job alive long after most executors are idle.


This post focuses on practical PySpark Data Skew Tuning: Salting, Broadcast Joins & AQE for engineers tuning production workloads on Azure Databricks.


Wide-angle view of unevenly sized data blocks beside compact server hardware
Skew is easy to miss until one partition becomes much larger than the rest.

How data skew causes shuffle OOM crashes and stranded tasks


Spark distributes work by partition. During a shuffle, records with the same join key or aggregation key usually move to the same reducer partition. If a key is extremely common, one shuffle partition receives a much larger share of data.


A non-skewed shuffle might look like this:


Partition

Shuffle read

Task duration

0

480 MB

42 s

1

510 MB

45 s

2

495 MB

43 s

3

505 MB

44 s


A skewed shuffle can look like this:


Partition

Shuffle read

Task duration

0

420 MB

39 s

1

390 MB

36 s

2

8.7 GB

18 min

3

410 MB

38 s


That large partition becomes dangerous because one task must process it. Spark can add executors, but it cannot split that one task unless the query plan or runtime feature allows it. The oversized task may:


  • Spill heavily to local disk.

  • Spend a long time in garbage collection.

  • Hit executor memory limits during sort, hash join, or aggregation.

  • Retry on another executor and fail again.

  • Hold up downstream stages while other tasks finish early.


This is how skew creates stranded tasks. Most of the stage completes, but one or two tasks keep running. In the Spark UI, the progress bar sits at something like 199/200 tasks for a long time. Cluster utilisation drops, yet the job is still not complete.


A common failure pattern in Databricks logs looks like this:


```text

ExecutorLostFailure

Container killed by YARN for exceeding memory limits


or


java.lang.OutOfMemoryError: Java heap space


or


org.apache.spark.shuffle.FetchFailedException

```


The exact message depends on cluster mode, runtime, and where the pressure occurs. The tuning process is the same: find the skew, then reduce the size of the largest per-task unit of work.


Start diagnosis in the Spark UI


In Azure Databricks, open the job run, then inspect the Spark UI.


Check these screens first:


  • Stages Look for stages where most tasks finish quickly but the max task duration is much higher than the median.


  • Tasks Sort by shuffle read size, spill, or duration. One task reading many times more data than the median is a strong skew signal.


  • SQL/DataFrame tab Find the physical operator causing the shuffle, such as `SortMergeJoin`, `HashAggregate`, `Exchange`, or `Window`.


  • Executors Check whether failures concentrate on specific stages rather than across the whole job.


You can also run quick profiling from PySpark before tuning the join.


```python

from pyspark.sql import functions as F


fact = spark.table("sales.fact_orders")


key_profile = (

fact.groupBy("customer_id")

.count()

.orderBy(F.desc("count"))

)


key_profile.show(20, truncate=False)


summary = key_profile.agg(

F.max("count").alias("max_key_rows"),

F.expr("percentile_approx(count, 0.50)").alias("median_key_rows"),

F.avg("count").alias("avg_key_rows")

)


summary.show()

```


If the biggest key is hundreds or thousands of times larger than the median, repartitioning alone will not fix the problem. Spark will still send the same hot key to the same reducer partition.


Close-up view of coloured stones sorted into uneven trays
A hot key behaves like one tray receiving most of the stones.

When raising autoBroadcastJoinThreshold helps


Broadcast joins avoid shuffle on the large side by sending a small table to every executor. In a typical fact-to-dimension join, this can remove the shuffle that exposes skew.


Spark controls automatic broadcast join selection with:


```text

spark.sql.autoBroadcastJoinThreshold

```


If a table is smaller than this threshold, Spark may choose a broadcast hash join instead of a shuffle join. Apache Spark’s common default is small, and Databricks Runtime settings can vary, so check the active value rather than assuming it.


```python

spark.conf.get("spark.sql.autoBroadcastJoinThreshold")

```


Raise the threshold when the dimension table is slightly too large


Raising `autoBroadcastJoinThreshold` is useful when all of these are true:


  • One side of the join is clearly small, usually a dimension or lookup table.

  • The small side is only slightly above the current threshold.

  • The small side fits safely in executor memory after serialisation.

  • The join currently uses `SortMergeJoin` and causes a large shuffle.

  • The row count and table size are stable enough for production use.


For example, suppose a customer dimension table is larger than the threshold, while the fact table has a skewed `customer_id`. A shuffle join will move fact rows by `customer_id`, pushing the hot customer into one reducer. A broadcast join sends the dimension to executors, letting each fact partition join locally.


Set the threshold at the session level for testing:


```python

spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 100 1024 1024) # 100 MB


fact = spark.table("sales.fact_orders")

dim_customer = spark.table("sales.dim_customer")


joined = fact.join(dim_customer, on="customer_id", how="left")


joined.explain("formatted")

```


Look for `BroadcastHashJoin` in the physical plan.


```text

BroadcastHashJoin [customer_id], [customer_id], LeftOuter, BuildRight

```


You can also use an explicit broadcast hint when testing a specific join:


```python

from pyspark.sql.functions import broadcast


joined = fact.join(

broadcast(dim_customer),

on="customer_id",

how="left"

)


joined.explain("formatted")

```


Do not raise it blindly


Broadcasting is not free. Every executor needs memory to hold the broadcasted relation. If the table has wide rows, nested columns, or poor compression, a threshold that looks safe from storage size may still hurt executor memory.


Avoid raising the threshold when:


  • Both sides are large fact tables.

  • The small table grows unpredictably.

  • Executors already run close to memory limits.

  • Many broadcast joins run in the same query.

  • The join condition is not selective and creates row explosion.


A good Databricks workflow is to compare plans before and after the change, then test with production-like data. Watch executor memory, broadcast time, and task duration. If broadcast removes the skewed shuffle stage, it is often the cleanest fix.


Eye-level view of a compact reference book duplicated beside several data crates
Broadcast joins copy the small side so large partitions can join locally.

How to implement key salting on skewed fact tables


Broadcast joins work only when one side is small enough. If the skewed join is between a very large fact table and another sizeable table, you need to split hot keys across multiple partitions. Key salting does exactly that.


Salting adds an extra value to the join key. Instead of all rows for a hot key going to one reducer, Spark spreads them across several salted keys.


For a skewed fact table, the usual pattern is:


  1. Add a salt column to the skewed fact table.

  2. Expand the matching dimension or right-side table for the hot keys across the same salt range.

  3. Join on the original key plus the salt.


Detect the hot keys first


Do not salt every key unless needed. Start by identifying the keys that cause skew.


```python

from pyspark.sql import functions as F


fact = spark.table("sales.fact_orders")

dim = spark.table("sales.customer_pricing")


hot_keys = (

fact.groupBy("customer_id")

.count()

.where(F.col("count") > 1_000_000) # Example threshold

.select("customer_id")

)


hot_keys.cache()

hot_keys.show(20, truncate=False)

```


The threshold should come from your data distribution. A simple rule is to mark keys far above the median or above a size that creates unsafe task memory.


Salt only the skewed keys


The next example salts only hot keys. Non-hot keys keep salt `0`, which avoids unnecessary data expansion.


```python

from pyspark.sql import functions as F


salt_buckets = 16


fact_with_hot_flag = (

fact.join(hot_keys.withColumn("is_hot", F.lit(True)), "customer_id", "left")

)


salted_fact = (

fact_with_hot_flag

.withColumn(

"salt",

F.when(

F.col("is_hot") == True,

(F.rand(seed=42) * salt_buckets).cast("int")

).otherwise(F.lit(0))

)

.drop("is_hot")

)

```


This spreads rows for each hot `customer_id` across 16 salt buckets. The right value depends on the skew size and cluster capacity. Too few buckets leave skew behind. Too many buckets add overhead.


Now expand the matching right-side rows for hot keys.


```python

hot_dim = (

dim.join(hot_keys, "customer_id", "inner")

.withColumn("salt", F.explode(F.sequence(F.lit(0), F.lit(salt_buckets - 1))))

)


normal_dim = (

dim.join(hot_keys, "customer_id", "left_anti")

.withColumn("salt", F.lit(0))

)


salted_dim = hot_dim.unionByName(normal_dim)

```


Then join using both columns.


```python

joined = salted_fact.join(

salted_dim,

on=["customer_id", "salt"],

how="left"

)


joined.explain("formatted")

```


Salt aggregations with care


Salting is also useful for skewed aggregations. Use a two-stage aggregation.


```python

salt_buckets = 32


salted = (

fact.withColumn(

"salt",

(F.rand(seed=99) * salt_buckets).cast("int")

)

)


partial = (

salted.groupBy("customer_id", "salt")

.agg(F.sum("order_amount").alias("partial_amount"))

)


final = (

partial.groupBy("customer_id")

.agg(F.sum("partial_amount").alias("total_amount"))

)

```


This reduces the amount of data one reducer handles for the hot key. The final aggregation still groups by `customer_id`, but it processes far fewer rows because the partial stage has already reduced them.


Salting has trade-offs. It adds columns, changes join logic, and may expand the right-side data. Use it where the skew cost is clearly higher than the extra processing.


How AQE dynamically coalesces partitions


Adaptive Query Execution, usually called AQE, lets Spark adjust parts of the query plan at runtime. In Azure Databricks, AQE is commonly used for SQL and DataFrame workloads, but always check your cluster or SQL warehouse configuration.


```python

spark.conf.get("spark.sql.adaptive.enabled")

```


Enable it for a test session if needed:


```python

spark.conf.set("spark.sql.adaptive.enabled", "true")

```


AQE uses runtime statistics from completed shuffle stages. One of its key features is post-shuffle partition coalescing. Spark may start with many shuffle partitions, then combine small adjacent partitions after it knows their actual sizes.


The setting below controls the initial shuffle partition count:


```python

spark.conf.set("spark.sql.shuffle.partitions", "800")

```


Without AQE, Spark would create 800 reduce partitions for many shuffle operations. That may produce too many tiny tasks when the data is smaller than expected.


With AQE coalescing enabled, Spark can combine those small partitions into fewer larger tasks.


```python

spark.conf.set("spark.sql.adaptive.enabled", "true")

spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")


spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", 128 1024 1024)


orders = spark.table("sales.fact_orders")


daily_sales = (

orders.groupBy("order_date")

.agg(F.sum("order_amount").alias("sales_amount"))

)


daily_sales.explain("formatted")

```


In the Spark UI, AQE plans may show adaptive plan changes. You may see operators such as:


```text

AdaptiveSparkPlan

AQEShuffleRead coalesced

```


Coalescing helps when shuffle partitions are too small. It reduces scheduling overhead and can make stages more efficient.


It does not automatically solve every hot-key problem. If one partition is huge because one key dominates, coalescing small partitions around it will not make that partition smaller. For skewed joins, AQE may also apply skew join handling when enabled and when the plan qualifies, but salting or broadcast may still be needed for severe or complex skew.


A useful pattern on Databricks is to combine a higher initial `spark.sql.shuffle.partitions` value with AQE coalescing. The high initial value gives Spark room to split work finer. AQE then reduces the cost of tiny partitions after it observes real shuffle sizes.


```python

spark.conf.set("spark.sql.adaptive.enabled", "true")

spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")

spark.conf.set("spark.sql.shuffle.partitions", "1000")


result = (

spark.table("sales.fact_orders")

.join(spark.table("sales.dim_product"), "product_id")

.groupBy("category_id")

.agg(F.sum("order_amount").alias("amount"))

)


result.write.mode("overwrite").format("delta").saveAsTable("sales.category_sales")

```


For production, keep these settings close to the workload, not scattered across notebooks. Cluster policies, job parameters, or shared utility modules make changes easier to review.


High-angle view of small metal containers being combined into larger containers
AQE can combine small shuffle partitions after Spark sees their real sizes.

A practical tuning flow for Azure Databricks jobs


Start with evidence. Skew tuning becomes messy when every setting changes at once.


Use this sequence for production investigations:


  1. Find the skewed stage


    Use the Spark UI. Sort tasks by duration, shuffle read, spill, and failed attempts.


  2. Find the skewed key


    Profile likely join or aggregation keys with `groupBy().count()` and compare max, median, and average counts.


  1. Check the physical plan


    Run `explain("formatted")`. Confirm whether Spark is using `SortMergeJoin`, `BroadcastHashJoin`, `HashAggregate`, or another expensive operator.


  2. Try broadcast when one side is small


    Raise `autoBroadcastJoinThreshold` only when the smaller side safely fits in executor memory. Prefer testing with a broadcast hint before changing broader settings.


  1. Use salting for severe hot keys


    Salt the skewed fact table and expand the matching right side only for hot keys. Keep the salt bucket count measurable and reviewed.


  2. Enable and verify AQE


    AQE coalescing helps with too many small shuffle partitions. It pairs well with higher initial shuffle partition counts, but it does not replace skew-specific fixes.


  1. Compare before and after


    Measure stage duration, max task duration, shuffle spill, failed tasks, and executor memory. The best fix reduces the worst task, not only the average task.


A simple logging pattern helps compare runs:


```python

def print_spark_settings():

keys = [

"spark.sql.autoBroadcastJoinThreshold",

"spark.sql.shuffle.partitions",

"spark.sql.adaptive.enabled",

"spark.sql.adaptive.coalescePartitions.enabled",

"spark.sql.adaptive.advisoryPartitionSizeInBytes",

]


for key in keys:

try:

print(f"{key} = {spark.conf.get(key)}")

except Exception:

print(f"{key} is not set")


print_spark_settings()

```


Also keep the tuned query small enough to reason about. If a notebook contains many joins, cache or write intermediate Delta tables during diagnosis. That makes the Spark UI easier to map back to the code.


The takeaway


Data skew is not fixed by adding more workers alone. If one key creates one oversized shuffle partition, Spark still has to run that oversized task somewhere. That is why skew leads to executor shuffle OOM crashes, repeated retries, and stranded tasks.


For Azure Databricks performance tuning, use the right fix for the shape of the problem:


  • Raise `autoBroadcastJoinThreshold` when a slightly larger dimension table can safely be broadcast and doing so removes a skewed shuffle.

  • Use key salting when a hot key in a large fact table must be split across multiple reducers.

  • Use AQE coalescing to reduce waste from many small shuffle partitions after Spark observes runtime data sizes.


The strongest workflow is evidence-led. Find the skew in the Spark UI, prove it with key distribution checks, inspect the physical plan, then apply the smallest change that reduces the largest task.


Comments


bottom of page