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.
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.

Reading the Spark UI: what to look for
Every optimization decision should start here. Guessing without the UI usually produces the wrong fix.
- Open the job’s Spark UI and go to the Stages tab. Sort by Duration descending.
- 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.
- In the Tasks table, sort by Input Size / Records and then Duration. Compare the largest task to the median.
- 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 × medianANDsize > skewedPartitionThresholdInBytes(default 256 MB). - 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.

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.
# 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

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.
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.
# 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"))
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 key | Default | What it controls |
|---|---|---|
spark.sql.shuffle.partitions | 200 | Number of output partitions for DataFrame/SQL shuffles |
spark.sql.adaptive.enabled | true (DBR 7.3+, Spark 3.2+) | Enables Adaptive Query Execution |
spark.sql.adaptive.advisoryPartitionSizeInBytes | 64 MB | AQE target size when coalescing small partitions |
spark.sql.adaptive.coalescePartitions.initialPartitionNum | same as shuffle.partitions | Upper bound AQE starts from when coalescing; set this high and let AQE reduce |
spark.sql.adaptive.skewJoin.enabled | true | AQE skew join splitting |
spark.sql.adaptive.localShuffleReader.enabled | true | Prefer local executor reads after dynamic broadcast promotion |
spark.sql.autoBroadcastJoinThreshold | 10 MB (OSS) / 10–100 MB (Databricks) | Table size threshold for automatic broadcast join |
spark.default.parallelism | 2 × cluster cores | Parallelism for RDD operations only — usually leave at default if you use DataFrames |
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:
- 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.
- 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.
- 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.
- 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.
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.
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:
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).
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.
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.
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.
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.
# 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.
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.
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.

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") )
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.
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.partitionsso 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=trueappears in the query plan), add salting for the hottest keys. - Audit your caching.
df.cache()ordf.persist()during a shuffle-heavy pipeline competes with shuffle buffers for executor memory. Unpersist DataFrames that are no longer needed withdf.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. Userepartition()to rebalance, and savecoalesce()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.
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.
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.
FAQ -Spark Shuffle Partitions Optimization
Why is my job slow only on one shuffle stage?
What’s a good starting value for spark.sql.shuffle.partitions?
spark.sql.adaptive.advisoryPartitionSizeInBytes = 128 MB as the target.Does AQE eliminate the need to tune shuffle partitions manually?
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?
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?
Should I use repartition() or coalesce() for shuffle optimization?
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.



