home
diamond Go Premium
Data Engineering Path  ·  PySpark

Solving Spark Data Skew: A Step-by-Step Diagnostic & Implementation Guide

Data skewness is one of the most common production issues in distributed computing, often responsible for increasing infrastructure costs by more than 5x and stalling jobs at the classic 199/200 tasks mark.

Skewness occurs when data is unbalanced across partitions, with a few partitions containing a disproportionately large volume of records compared to the rest. Because a single CPU core processes exactly one partition at a time, skewed data forces a single executor to work infinitely longer than others, leaving your cluster resource utilization highly uneven.

Below is the definitive, step-by-step diagnostic and remediation playbook using PySpark and Salting.


️ Step 1: Diagnose Skewness in the Spark Web UI

Before writing any optimization code, you must first verify that data skewness is indeed the root cause of your bottleneck.

  1. Open your Spark Web UI (default: http://localhost:4040).
  2. Go to the Stages tab and click on the active stage that is running slowly or stuck.
  3. Scroll down to the Summary Metrics for Completed Tasks table.

Under normal execution, task durations and read sizes should be evenly distributed. Under a data skew scenario, you will see a massive disparity between the 75th Percentile and the Max task metrics.

Spark UI Data Skew Diagnosis

Reading the Diagnostic Metrics:

  • Task Duration Distribution: In the graph, 99 tasks completed in under 3 seconds, while a single outlier task (Task 15) is running for 25.2 minutes.
  • Duration Metrics:
  • Min: 850 ms
  • Median (50th Pct): 1.2 s
  • 75th Pct: 2.5 s
  • Max: 25.1 minThis is a 600x skew ratio!
  • Shuffle Read Size: The maximum shuffle read is 25.3 GB while the median is 25.3 GB (or the max input size is 250M records vs 100k records for other partitions).

This extreme variance confirms that a single partition contains nearly all the active data, causing CPU Core exhaustion on Executor ID 12.


Step 2: Login to Driver Host & Audit Partition Counts

Once the UI confirms the skew, login to your driver host (or notebook console) to programmatically identify which partitions are overloaded and inspect their row counts.

We use the PySpark built-in function spark_partition_id() to group your DataFrame by partition boundaries:

from pyspark.sql import functions as F

# 1. Audit partition distribution programmatically
audit_df = df.withColumn("partition_id", F.spark_partition_id()) \
             .groupBy("partition_id") \
             .count() \
             .orderBy("partition_id")

# Show the counts for each partition
audit_df.show()

Sample Audit Output:

+------------+------------+
|partition_id|count(rows) |
+------------+------------+
|           0|      999990|  <-- ️ Skewed Partition
|           1|           5|
|           2|           5|
+------------+------------+

As the audit reveals, Partition 0 holds 999,990 rows while Partition 1 and 2 hold only 5 rows. During a subsequent join or aggregation, the executor core assigned to Partition 0 will choke while the other executor cores finish instantly and sit idle.


Step 3: Create a Salt Key

To resolve this imbalance, we use Salting.

Why do we need a Salt Key?

By default, Spark uses a hash partitioner to route records during joins. It computes hash(join_key) % num_partitions. If a join key is extremely frequent (e.g., join_key = null or a generic value like 'VIP_USER'), all those records will compute to the exact same hash value and land in the exact same partition.

By adding a random salt key to the join key, we break up the giant hot key into smaller, distinct virtual keys (e.g., VIP_USER_0, VIP_USER_1, VIP_USER_2), forcing Spark to distribute them across separate partitions.

How do we create the Salt Keys?

We define a salt_number (e.g., 3) and apply modifications to both DataFrames being joined:

import sys
from pyspark.sql import functions as F

# Define salt factor based on the number of partitions we want to distribute the skew across
salt_number = 3

# ----------------------------------------------------
# 1. SALT THE LARGE (SKEWED) DATAFRAME
# ----------------------------------------------------
# Add a random integer column between [0, salt number - 1]
large_df_salted = large_df.withColumn(
    "salt_key", 
    F.floor(F.rand() * salt_number)
)

# Create a composite salted join key
large_df_salted = large_df_salted.withColumn(
    "salted_join_key", 
    F.concat(F.col("original_key"), F.lit("_"), F.col("salt_key"))
)

# ----------------------------------------------------
# 2. SALT THE SMALL DATAFRAME (REPLICATION)
# ----------------------------------------------------
# Since the large DF keys are split randomly, the small DF must replicate its rows
# so it can match ANY of the possible random salt values [0, 1, 2].
salt_array = F.array([F.lit(i) for i in range(salt_number)])

small_df_replicated = small_df.withColumn("salt_key_arr", salt_array) \
                              .withColumn("salt_key", F.explode("salt_key_arr"))

# Create the corresponding composite salted join key
small_df_salted = small_df_replicated.withColumn(
    "salted_join_key", 
    F.concat(F.col("original_key"), F.lit("_"), F.col("salt_key"))
)

️ Step 4: What is the Salt Key Actually Doing?

To understand how salting solves resources starvation, look at how the physical data partitions are reorganized:

BEFORE SALTING (All skewed keys go to one core):
Large DF: [VIP_USER, VIP_USER, VIP_USER, VIP_USER, VIP_USER, VIP_USER] ---> Partition 0 (Core 0 Choking)
Small DF: [VIP_USER]                                                   ---> Partition 0

AFTER SALTING (Distributed evenly across multiple cores):
Large DF: [VIP_USER_0, VIP_USER_1, VIP_USER_2, VIP_USER_0, VIP_USER_1, VIP_USER_2]
            |             |             |             |             |             |
            v             v             v             v             v             v
       Partition 0   Partition 1   Partition 2   Partition 0   Partition 1   Partition 2
        (Core 0)      (Core 1)      (Core 2)      (Core 0)      (Core 1)      (Core 2)
            ^             ^             ^             ^             ^             ^
            |             |             |             |             |             |
Small DF: [VIP_USER_0,  VIP_USER_1,  VIP_USER_2,  VIP_USER_0,  VIP_USER_1,  VIP_USER_2] (Exploded)

By appending a random salt (0, 1, or 2), Spark's hash partitioner routes:

  • VIP_USER_0 records to Partition 0 (processed by Core 0).
  • VIP_USER_1 records to Partition 1 (processed by Core 1).
  • VIP_USER_2 records to Partition 2 (processed by Core 2).

The intensive join operation is now split across three parallel CPU cores, ensuring uniform hardware utilization.


Step 5: Remove the Salt Key and Output Results

Once the join is successfully computed, the salt keys are no longer needed. We must clean up our schema by dropping the temporary composite keys and salt columns to restore the original table structure.

# 1. Perform the join on the composite salted join key
joined_df = large_df_salted.join(
    small_df_salted, 
    on="salted_join_key", 
    how="inner"
)

# 2. Drop the temporary salt-keys and composite keys
final_clean_df = joined_df.drop("salted_join_key", "salt_key", "salt_key_arr")

# 3. View audit partition counts post-join to confirm skew resolution
final_clean_df.withColumn("partition_id", F.spark_partition_id()) \
              .groupBy("partition_id") \
              .count() \
              .show()

Sample Final Cleaned Result:

If we fetch a sample of our joined and cleaned DataFrame, it matches your original schema exactly with no trace of the salt variables:

+------------+------------+-----------------+------------------+
|original_key|  user_name |    action_type  | transaction_value|
+------------+------------+-----------------+------------------+
|   VIP_USER | Amit Prasad|    click_banner |             0.00 |
|   VIP_USER | Mukesh Sen |    add_to_cart  |            89.50 |
|   VIP_USER | Amit Prasad|    item_checkout|           120.00 |
+------------+------------+-----------------+------------------+

By removing the salt key, the output is perfectly returned to your business format, while the behind-the-scenes processing completed in a fraction of the time!


Follow-Up Questions & Answers

Q1: How do you decide the optimal salt_number?

A: The formula is: salt_number = ceil(skewed_partition_rows / target_partition_size). If the skewed partition has 999,990 rows and you want ~100K rows per partition, use salt_number = 10. Going too high wastes memory by over-replicating the small DataFrame.


Q2: What if BOTH DataFrames are large (1 billion rows each)?

A: Salting still works, but the replication cost of the small table becomes expensive. For two equally large tables:

  1. Use AQE (Adaptive Query Execution): Enable spark.sql.adaptive.skewJoin.enabled=true in Spark 3.0+. AQE automatically detects and splits skewed partitions at runtime.
  2. Two-Pass Join: Separate the hot key from the rest, broadcast-join the hot key portion, and do a regular join for the rest.
  3. Bucketed Tables: Pre-bucket both tables by the join key with the same number of buckets. Spark can do a shuffle-free bucket join.

Q3: Why not just increase spark.sql.shuffle.partitions to fix the skew?

A: Increasing shuffle partitions from 200 to 2000 makes each partition smaller, but it does NOT fix skew. The hot key still hashes to a single partition. If VIP_USER accounts for 60% of data, it will be 60% of whatever partition it lands on — regardless of how many partitions exist.

200 partitions:  Partition 47 = 999,990 rows (60%)
2000 partitions: Partition 471 = 999,990 rows (60%)  ← Same problem, different partition number!

Salting is necessary because it fundamentally changes the hash by appending a random suffix to the key.


Q4: Can AQE (Adaptive Query Execution) replace salting entirely?

A: For moderate skew (one partition 5-10x larger), AQE is often sufficient. For extreme skew (one partition 100x+ larger), AQE may not split aggressively enough. The safest approach is: enable AQE always, and add salting for known extreme hot keys.


Q5: How do you detect data skew proactively before the job runs?

A: Run a profiling step before the main job:

# Pre-job profiling: detect skew before it crashes the pipeline
key_distribution = df.groupBy("original_key").count()
stats = key_distribution.agg(
    F.max("count").alias("max_count"),
    F.avg("count").alias("avg_count"),
    (F.max("count") / F.avg("count")).alias("skew_ratio")
).collect()[0]

if stats["skew_ratio"] > 10:
    print(f"⚠️ SKEW DETECTED: max={stats['max_count']}, avg={stats['avg_count']}, ratio={stats['skew_ratio']:.1f}")
    # Automatically enable salting
    salt_number = max(3, int(stats["skew_ratio"] / 5))

Sub-Scenarios

Sub-Scenario A: Skew Caused by NULL Keys

Situation: 40% of your records have original_key = NULL. All NULLs hash to the same partition.

Fix: Handle NULLs separately:

null_df = df.filter(F.col("original_key").isNull())
non_null_df = df.filter(F.col("original_key").isNotNull())

# Process non-null keys with normal join
result = non_null_df.join(small_df, on="original_key")

# Handle NULLs based on business logic (drop, assign default, or process separately)

Sub-Scenario B: Skew in a GroupBy Aggregation (Not a Join)

Situation: You're computing SUM(amount) GROUP BY merchant_id, and one merchant has 60% of transactions. The aggregation for that one merchant runs on a single core.

Fix: Use two-stage aggregation with salting:

# Stage 1: Partial aggregation with salt
partial = df.withColumn("salt", F.floor(F.rand() * 10)) \
    .groupBy("merchant_id", "salt") \
    .agg(F.sum("amount").alias("partial_sum"))

# Stage 2: Final aggregation (removing salt)
final = partial.groupBy("merchant_id") \
    .agg(F.sum("partial_sum").alias("total_amount"))

Sub-Scenario C: Skew After a Repartition

Situation: You used df.repartition("merchant_id") to optimize for downstream operations, but now Amazon's 60% of data lands in one partition.

Fix: Use repartition with a salt column or use repartition(200) (round-robin on number of partitions) instead of repartitioning by the skewed key.

Find this content helpful? ☕ Buy me a coffee

Entity Details

Create New Item

celebration
Enjoying the free content?

Create a free account to track your progress and save your place.

Create Free Account
help

Submit Technical Query

Have a question or run into an issue? Describe it below, upload an optional screenshot, and our engineering team will answer it!

image Attach image (optional)

Submit Feedback

build Free Developer Utility Free Tool
gavel

Privacy & Legal Disclaimer

1. Client-Side Browser Processing

All utility tools on DeepEngineerHub (including Image to PDF, Text Formatters, JSON Converters, and Encryptors) execute 100% locally within your client browser using WebAssembly and JavaScript. No uploaded images, text, or documents are transmitted, collected, or stored on remote servers.

2. Limitation of Liability ("As-Is" Provision)

Tools and services are provided free of charge for convenience and educational purposes "as-is" without warranties of any kind. DeepEngineerHub shall not be held liable for any data loss, formatting inconsistencies, or indirect damages resulting from tool usage.

3. Open Source & Third-Party Software

Certain utilities utilize open-source client libraries (such as jsPDF, Mermaid.js, Pyodide) licensed under MIT, Apache, or BSD open licenses. All intellectual property remains with their respective copyright holders.