Apache Spark · Data Engineering · Verified June 2026

Spark Shuffle Partitions Optimization

Why slow Spark jobs almost always come down to shuffle. How to diagnose skew in the Spark UI, calculate the right partition count, and — when tuning the number isn’t enough — eliminate the shuffle entirely with Storage Partition Join or broadcast hints.

Slow stage diagnostic — start here
Slow or failing Spark stage?
├─ Is it a shuffle stage? (join / groupBy / window)
│ ├─ NoCPU/GC/IO issue. Not a partition problem.
│ └─ Yes
│ │
│ ├─ All tasks slow, large partitionsRaise shuffle.partitions + enable AQE
│ │
│ └─ 1–2 tasks far slower (skew)
│ ├─ 1–2 keys account for >50% of rowsSalt the key
│ └─ Evenly distributed, AQE not firingCheck coalesce() vs repartition()
└─ Can you eliminate the shuffle?
├─ Small table (<100 MB on Databricks)Broadcast join
└─ Both tables co-partitioned on join keyStorage Partition Join (SPJ)
Verified: June 2026 Spark 3.5 / 4.0 · Databricks Runtime 17.0 By A.J., UIG Data Lab Spark SQL Performance Tuning docs
The root cause

What is Spark shuffle and why do shuffle partitions matter?

Shuffle is the step where Spark physically moves data between executors. It fires on any wide transformation — a join, groupBy, window function, or explicit repartition. During a shuffle, Spark writes intermediate data to local disk (shuffle write), then reads it back across the network into new partitions (shuffle read). The number of those new partitions is controlled by spark.sql.shuffle.partitions.

The default is 200. That was a reasonable guess in 2015 for a medium-sized cluster. In 2025, on a 100-node job processing terabytes, 200 partitions means each partition can be multiple gigabytes — the single most common cause of OOM errors and hour-long garbage collection pauses.

Spark shuffle: data moves between executors; shuffle partitions control how that work is split
Shuffle moves data across the network. Too few partitions → each partition too large, OOM risk. Too many → scheduling overhead dominates.
Diagnosis

Reading the Spark UI: what to look for

Every optimization decision should start here. Guessing without the UI usually produces the wrong fix.

  1. Open the job’s Spark UI and go to the Stages tab. Sort by Duration descending.
  2. Click the slowest stage. Confirm it contains an Exchange node in the DAG (that’s the shuffle). If there’s no Exchange, the problem is elsewhere.
  3. In the Tasks table, sort by Input Size / Records and then Duration. Compare the largest task to the median.
  4. Skew signal: if the largest task’s input is more than 3× the median, or its runtime is more than 3× the median, you have a skew problem. AQE formally defines a skewed partition as one where size > skewedPartitionFactor × median AND size > skewedPartitionThresholdInBytes (default 256 MB).
  5. Volume signal: if all tasks look similar but each one handles several hundred MB or more, you need more partitions overall — not a salting fix.
Spark UI stage view showing skewed tasks — a few tasks with far larger input size than the rest
Skew in the Spark UI: 1–2 tasks with input size or duration several times the median. The long orange bar is the skewed task; the rest finish quickly and sit idle.
Sizing

Calculating the right number of shuffle partitions

Target 100–200 MB per shuffle partition. Divide the shuffle data size of your largest stage by that target. Read the shuffle write size from the Spark UI stage summary — it’s labeled Shuffle Write.

Partition count formula — Python
# Read shuffle_write_gb from the Spark UI → Stage Summary → Shuffle Write
shuffle_write_gb  = 100    # e.g., 100 GB largest shuffle stage
target_mb         = 128    # target partition size — keep under 200 MB

ideal_partitions  = int((shuffle_write_gb * 1024) / target_mb)
# 100 × 1024 / 128 = 800 partitions

# Sanity checks:
# ✓ Result should be ≥ 2–5 × (number of available cluster cores)
# ✓ Each partition should stay below ~200 MB to avoid GC pressure
# ✗ Don't exceed ~5000–8000 — task scheduling overhead wins at extreme counts

# Bad: 100 GB ÷ 200 partitions = ~512 MB each → OOM likely
# Good: 100 GB ÷ 800 partitions = ~128 MB each → balanced
Formula: shuffle stage size divided by target partition size gives ideal partition count
Shuffle stage size ÷ target size = ideal partition count. Clamp so you have at least 2–5 tasks per core.
When AQE is on

With AQE enabled, set spark.sql.shuffle.partitions to a generous upper bound (e.g., 2,000–4,000) and let AQE coalesce partitions down at runtime based on actual shuffle statistics. AQE’s coalescing uses spark.sql.adaptive.advisoryPartitionSizeInBytes (default 64 MB; raise to 128 MB for most production jobs) as its target. The formula above is most useful when AQE is off or when you want a tighter starting value.

Configuration

Setting the config — PySpark, Scala, and the key AQE knobs

Set spark.sql.shuffle.partitions early in your notebook or job script — before any wide transformation. In Databricks cluster settings you can also set it as a cluster default, but notebook-level config always takes precedence.

PySpark — shuffle partitions + AQE
# 1. Set initial partition count based on your largest shuffle stage
spark.conf.set("spark.sql.shuffle.partitions", "800")

# 2. Enable AQE (default on in Databricks 7.3+ and OSS Spark 3.2+)
spark.conf.set("spark.sql.adaptive.enabled", "true")

# 3. AQE advisory partition size — how large a coalesced partition should be
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "134217728")  # 128 MB

# 4. Skew join handling (enabled by default when AQE is on)
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")

# 5. Local shuffle reader — prefer local executors for shuffle reads
spark.conf.set("spark.sql.adaptive.localShuffleReader.enabled", "true")

# Then run your heavy operations
df_result = df1.join(df2, "user_id") \
               .groupBy("country") \
               .agg(F.sum("revenue").alias("total"))
Scala — same config pattern
spark.conf.set("spark.sql.shuffle.partitions", "800")
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "134217728")

val dfResult = df1.join(df2, Seq("user_id"))
                  .groupBy("country")
                  .agg(sum("revenue").alias("total"))
Config keyDefaultWhat it controls
spark.sql.shuffle.partitions200Number of output partitions for DataFrame/SQL shuffles
spark.sql.adaptive.enabledtrue (DBR 7.3+, Spark 3.2+)Enables Adaptive Query Execution
spark.sql.adaptive.advisoryPartitionSizeInBytes64 MBAQE target size when coalescing small partitions
spark.sql.adaptive.coalescePartitions.initialPartitionNumsame as shuffle.partitionsUpper bound AQE starts from when coalescing; set this high and let AQE reduce
spark.sql.adaptive.skewJoin.enabledtrueAQE skew join splitting
spark.sql.adaptive.localShuffleReader.enabledtruePrefer local executor reads after dynamic broadcast promotion
spark.sql.autoBroadcastJoinThreshold10 MB (OSS) / 10–100 MB (Databricks)Table size threshold for automatic broadcast join
spark.default.parallelism2 × cluster coresParallelism for RDD operations only — usually leave at default if you use DataFrames
Adaptive Query Execution

What AQE fixes — and where it can’t help

AQE is Spark’s runtime re-optimizer. It fires at shuffle boundaries where real statistics become available, and uses those statistics to make better decisions than the static plan could. It has four features:

  1. Dynamic partition coalescing: merges adjacent small shuffle partitions into larger ones, so a job with 2,000 initial partitions may finish with 80 actual tasks.
  2. Dynamic sort-merge join → broadcast hash join: if a filtered table turns out to be small enough to broadcast after the shuffle, AQE promotes the join strategy mid-execution, avoiding a second sort-merge phase.
  3. Skew join handling: splits and optionally replicates skewed partitions so the hot-key task is processed by multiple tasks in parallel instead of one overwhelmed executor.
  4. Empty relation propagation: if a join side is empty (e.g., a filter returns zero rows), AQE short-circuits the entire join branch rather than executing it.

Spark 4.0 (released May 2025, Databricks Runtime 17.0 June 2025) refines AQE further, with internal benchmarks showing complex queries running up to 30% faster than Spark 3.x and up to 3× faster than Spark 2.x — primarily from improved partition coalescing and join strategy switching.

AQE limitations — the gaps it doesn’t fill

coalesce() blocks AQE. AQE fires only at shuffle boundaries. If you call df.coalesce(n) instead of df.repartition(n), no shuffle occurs — so AQE never gets statistics to act on. A skewed partition that goes through coalesce() stays skewed, invisibly.

LEFT OUTER JOIN — skew only on the left. AQE skew join optimization for LEFT OUTER JOINs can only split tasks where the skew is on the left side. Right-side skew in a left outer join is not handled by AQE.

Static broadcast joins beat dynamic ones. If Spark initially plans a Sort-Merge Join and AQE later promotes it to broadcast, the promotion happens after the shuffle for both sides already ran. Using a broadcast() hint explicitly when you know the table is small avoids that wasted shuffle. AQE respects explicit hints, but it can’t travel back in time to skip the first shuffle pass.

Strategy selection

Join strategy decision — in order of preference

Choosing the right join strategy can eliminate shuffle entirely. Tuning spark.sql.shuffle.partitions only helps if you’re already on the right strategy. Work top-down through this hierarchy for every expensive join:

Best

Broadcast Hash Join

One side fits in executor memory. No shuffle on the small side. Use broadcast() hint or raise autoBroadcastJoinThreshold. Threshold is 10 MB in OSS Spark; Databricks uses a higher default (10–100 MB range).

No shuffle needed

Storage Partition Join (SPJ)

Both sides are co-partitioned on the join key (Iceberg, Delta, or other DataSource V2). No Exchange node in the plan. Spark 3.3+, matured in 3.4. See the SPJ section below.

Shuffle required

Sort-Merge Join

Default for large equi-joins. Robust but expensive — both sides shuffle and sort. Tune shuffle.partitions and enable AQE skew handling here.

Avoid

Cartesian / Nested Loop

Non-equi joins only. Produces an O(n × m) output. No partition tuning helps here — the problem is the join condition, not the config.

Advanced — eliminate shuffle

Storage Partition Join (SPJ): no shuffle, no config to tune

Storage Partition Join (Spark 3.3+, matured in 3.4) exploits the storage layout of DataSource V2 tables — Apache Iceberg, Delta Lake, Apache Hudi — to skip the shuffle phase of a join entirely. If both tables are partitioned on the same column with the same partition function, Spark can co-locate matching data without moving it. The Spark UI shows no Exchange node in the physical plan when SPJ is active.

SPJ generalizes Bucket Joins (which work only for bucketed Hive tables) to any DataSource V2 table that reports its partitioning. At Expedia Group’s scale, enabling SPJ on co-partitioned Iceberg tables eliminated the shuffle and produced benchmark speedups that justified restructuring their table layouts specifically for it.

PySpark — enabling SPJ for Iceberg or Delta tables
# Both tables must be partitioned on the same column with matching partition specs
# Example: both tables partitioned by 'region' or bucket(8, 'customer_id')

spark.conf.set("spark.sql.sources.v2.bucketing.enabled", "true")
spark.conf.set("spark.sql.sources.v2.bucketing.pushPartValues.enabled", "true")
spark.conf.set("spark.sql.iceberg.planning.preserve-data-grouping", "true")
spark.conf.set("spark.sql.requireAllClusterKeysForCoPartition", "false")
spark.conf.set("spark.sql.sources.v2.bucketing.partiallyClusteredDistribution.enabled", "true")

# Verify SPJ is working: no Exchange node should appear
df_orders.join(df_customers, "region").explain("FORMATTED")
# If Exchange is absent → SPJ is active. If Exchange appears → co-partitioning mismatch.
When SPJ doesn’t fire

SPJ requires both tables to be partitioned on exactly the join column with a compatible partition spec. It doesn’t apply to non-equi joins. If you’re joining on a column that the tables aren’t partitioned by, SPJ won’t fire and you’ll fall through to a Sort-Merge Join. The benefit of restructuring table partitioning to enable SPJ tends to justify itself only when the same join runs repeatedly in production pipelines — it’s a write-once, read-many optimization.

Hot-key skew

Salting: when AQE skew handling isn’t enough

AQE’s skew join feature splits large partitions at runtime, but it works within the existing data distribution — it can’t split a partition whose entire content is rows with a single key value. If 30% of your fact table has country = 'US', AQE can split the task but Spark still needs to re-replicate the matching dimension rows to all split tasks, which adds its own overhead. Past roughly 5–10× skew on a single key, manual salting gives cleaner results.

Salting: a hot key is split into sub-keys using a random salt, spreading its rows across multiple partitions
Salting appends a random bucket number to the join key. The hot key’s rows spread across N partitions. The dimension table’s matching rows are cross-joined with 0..N to match.
PySpark — salting a skewed join
from pyspark.sql import functions as F

skewed_col  = "user_id"
n_buckets   = 8       # spread hot keys across 8 sub-partitions

# Large (skewed) side: append a random salt column
df_large_salted = df_large.withColumn(
    "salt",
    (F.rand() * n_buckets).cast("int")
)

# Small side: cross-join with 0..n_buckets-1 so every salt value has a match
df_small_expanded = df_small.crossJoin(
    spark.range(n_buckets).toDF("salt")
)

# Join on original key + salt
df_joined = df_large_salted.join(
    df_small_expanded,
    on=[skewed_col, "salt"],
    how="inner"
)

# Aggregate and drop salt — result is identical to the unsalted join
df_result = df_joined.groupBy(skewed_col).agg(
    F.sum("metric").alias("metric_sum")
)
Choosing the bucket count

Start with 8–16 buckets for moderate skew. If a single key accounts for >50% of rows, the hot key’s portion still lands on one bucket — raise to 32 or 64. More buckets also inflate the cross-join on the small side, so balance the two. Too many buckets (e.g., 256+) can add scheduling overhead that negates the gain.

OOM prevention

Preventing OutOfMemory errors from shuffle

OOM during a shuffle stage almost always means one or more partitions don’t fit in executor memory. The checklist below covers all common causes — work through it in order rather than just raising spark.executor.memory first.

  • Raise spark.sql.shuffle.partitions so each partition targets 100–200 MB. Check the Spark UI to confirm task sizes after the change.
  • If skewed keys drive the OOM, AQE skew join handling is the first remedy. If AQE doesn’t fully fix it (check whether isSkew=true appears in the query plan), add salting for the hottest keys.
  • Audit your caching. df.cache() or df.persist() during a shuffle-heavy pipeline competes with shuffle buffers for executor memory. Unpersist DataFrames that are no longer needed with df.unpersist().
  • Don’t call coalesce() before a heavy join. coalesce reduces partition count without shuffling, which can create very large input partitions that then explode during the join’s shuffle. Use repartition() to rebalance, and save coalesce() for after the join when you want fewer output files.
  • Check the Spark event log for GC metrics. If GC time is >10% of task time, the JVM is struggling with memory — reducing partition size or switching to Kryo serialization (spark.serializer=org.apache.spark.serializer.KryoSerializer) helps.
Platform specifics

Databricks and Microsoft Fabric: what’s different

Databricks

AQE is on by default from Databricks Runtime 7.3 and has been progressively improved. Databricks Runtime 17.0 (June 2025, Spark 4.0) brings the latest AQE refinements including improved SPJ support (SPARK-49839: SPJ now skips shuffles for sorts too), enhanced partition coalescing, and internal benchmarks of 20–50% speedups on complex workloads vs Spark 3.x.

The autoBroadcastJoinThreshold in Databricks is higher than the 10 MB OSS default — Databricks manages this dynamically based on cluster memory. The Photon engine (enabled by default on many compute types from DBR 9.1 LTS+) accelerates scan-heavy and aggregation workloads independently of shuffle tuning, so combine both.

Set cluster-level config in the cluster’s Spark Config field for job-wide defaults. Use notebook-level spark.conf.set() to override for specific heavy stages.

Microsoft Fabric

Fabric Spark notebooks honor all the same configuration parameters. Use spark.conf.set() at the notebook level. The Fabric Spark UI exposes the same Stages/Tasks view for diagnosing skew and partition sizes — the diagnostic process is identical to the Spark UI steps above.

Combine shuffle tuning with Fabric-specific lakehouse design: Delta table OPTIMIZE (which compacts small files and applies Z-ordering) reduces the volume of data scanned before the shuffle, which means the shuffle itself starts with less data. See the Fabric Lakehouse Tutorial for the full table optimization workflow.

Revisit when data shape changes

A spark.sql.shuffle.partitions value that’s correct today becomes wrong when a source table grows 2× or when you add a new business segment that creates a new hot key. Check the Spark UI again whenever data volume changes noticeably — the formula still gives you the right answer, you just need to re-run it against the new largest shuffle stage size.

Common questions

FAQ -Spark Shuffle Partitions Optimization

Why is my job slow only on one shuffle stage?
A single slow stage with an Exchange node in the DAG means shuffle is dominating there. Open that stage, sort tasks by input size and duration. If task sizes are uneven, it’s skew. If all tasks are large, it’s overall volume (not enough partitions). The diagnostic tree at the top of this page walks through both paths.
What’s a good starting value for spark.sql.shuffle.partitions?
Divide your largest shuffle stage’s write size (read from the Spark UI) by 128 MB. For 100 GB: 100 × 1024 / 128 = 800 partitions. Clamp so you have at least 2–5 tasks per available core. If AQE is enabled, set this as a generous upper bound (e.g., 2,000–4,000) and let AQE coalesce down using spark.sql.adaptive.advisoryPartitionSizeInBytes = 128 MB as the target.
Does AQE eliminate the need to tune shuffle partitions manually?
No. AQE coalesces and splits partitions at runtime but starts from your initial spark.sql.shuffle.partitions. A very wrong starting value means more corrective work for AQE. More importantly, AQE only fires at shuffle boundaries — so coalesce(), which produces no shuffle, gives AQE no statistics to act on. LEFT OUTER JOINs can only be skew-optimized on the left side. Explicit broadcast hints still beat AQE’s dynamic broadcast promotion when the table is known to be small, because AQE’s promotion happens after the first shuffle already ran.
When should I use salting vs. just raising the partition count?
Raise shuffle.partitions when data is balanced and each partition is too large due to volume. Salt when 1–2 keys disproportionately dominate the data. Raising the partition count can’t fix hot-key skew because all rows with the same key always land in the same partition, regardless of how many partitions there are.
What is the difference between spark.sql.shuffle.partitions and spark.default.parallelism?
spark.sql.shuffle.partitions controls output partitions for DataFrame and SQL shuffle stages — this is the knob that matters for most production workloads. spark.default.parallelism applies mainly to RDD operations and as a fallback when Spark has no better estimate. If your code uses DataFrames and Spark SQL (which it should), tune shuffle.partitions and leave the other at default.
What is Storage Partition Join and when does it make sense?
SPJ (Spark 3.3+) uses the existing storage layout of DataSource V2 tables to skip the shuffle phase of a join entirely when both tables are co-partitioned on the join key. The Spark UI shows no Exchange node when it’s working. It’s most valuable for large, repeatedly-joined tables in production pipelines where restructuring the partition layout pays off over many runs. For one-off or ad-hoc joins, the setup cost usually doesn’t justify it.
Should I use repartition() or coalesce() for shuffle optimization?
Use repartition(n) to rebalance data before a heavy join or aggregation — it triggers a full shuffle and distributes rows evenly. Use coalesce(n) after the main computation, just before writing results, to reduce output files without another full shuffle. Never use coalesce() before a join to “pre-partition” — it creates uneven large partitions and blocks AQE from seeing any statistics.

Accuracy note: AQE behavior and defaults are sourced from the Apache Spark 3.5/4.0 documentation, Databricks AQE documentation (AWS and Azure), and the Databricks Runtime 17.0 release notes (June 2025). Spark 4.0 benchmark figures (20–50% speedup vs Spark 3.x) are from the Databricks community ML blog (August 2025) and should be treated as indicative rather than universal. Storage Partition Join sources include the official Spark SQL Performance Tuning docs and the Expedia Group Engineering blog. UIG Data Lab is an independent publication and isn’t affiliated with or endorsed by the Apache Software Foundation, Databricks, or Microsoft Corporation.

AJ
A.J. Data Engineering Researcher & Technical Writer · UIG Data Lab

A.J. covers data engineering, Apache Spark, Microsoft Fabric, and cloud data platforms for UIG Data Lab. Content is built from official documentation, runtime release notes, and production-grade deployment patterns.

Apache Spark Shuffle Optimization AQE Databricks Microsoft Fabric PySpark Data Engineering

Scroll to Top