Home › ML / AI › Spark Heap OutOfMemory Fix: Driver vs Executor
Advanced 5 min · September 23, 2026

Spark Heap OutOfMemory Fix: Driver vs Executor

Split driver from executor OOM first: stop collect abuse, salt skewed keys, and size each memory pool deliberately.

N
Naren Founder & Principal Engineer

20+ years shipping production ML systems and the infrastructure behind them. Drawn from code that ran under real load.

Follow
✓ Production
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 16 min
  • ✓Python and Spark basics
  • ✓How distributed joins work
  • ✓Reading Spark UI stages
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Driver OOM comes from collect, toPandas, and giant broadcasts — stop pulling distributed data onto one machine
  • Executor OOM comes from skew, giant partitions, and oversized shuffles — salt hot keys and repartition deliberately
  • Size spark.driver.memory and spark.executor.memory separately; raising the wrong one burns money and fixes nothing
  • Read the task-duration histogram: one 41-minute task beside 200 fast ones is skew, not a sizing problem
  • Enable GC logging to separate churn (frequent young GC) from leaks (old-gen growth that never recovers)
✦ Definition~90s read
What is Spark Heap OutOfMemory Fix?

Spark distributes work but centralizes coordination, and heap failures follow that fault line. The driver holds plans, accumulates results, and assembles broadcasts — small by design, lethal when treated as storage. Executors hold partitions, shuffle blocks, and caches — many heaps working in parallel, each killed independently when its share concentrates beyond capacity.

★
Picture a restaurant kitchen.

One exception text covers both because both are JVM heap exhaustion; everything else about them differs.

Data shape, not cluster size, decides most outcomes. A hot key funnels terabytes onto one heap regardless of node count. A collect funnels cluster output onto one heap regardless of executor memory. Partition counts set task sizes before any byte is read.

Memory settings then scale healthy shapes — they cannot rescue pathological ones, and spending on pathological shapes multiplies waste by the node count. This is why histogram-first debugging beats settings-first guessing by orders of magnitude.

The mature posture is measurement plus gates. Top-key shares, partition sizes, broadcast bytes, GC shapes, and driver-heap trends get logged per run, alerted on drift, and reviewed before increases. Adaptive execution absorbs the moderate cases automatically while explicit salting handles the atomic ones.

Teams operating this way spend less on bigger clusters every quarter — because their shapes stay healthy as data grows, their pools track measured peaks, and their heap deaths become rare, fast-classified events instead of weekly fires.

Plain-English First

Picture a restaurant kitchen. The head chef (the driver) coordinates but shouldn't eat every dish — yet someone ordered the chef to taste all 10,000 plates (that's collect), and the chef collapses. Meanwhile one line cook got every well-done steak order because tickets skewed — 400 steaks for one cook, 3 for the rest — and that cook collapses too. Different collapses, different fixes.

Spark OutOfMemoryError: Java heap space splits into two diseases sharing one name. Driver OOM kills the coordinator — the single JVM running your main() — usually because collect(), toPandas(), or an oversized broadcast dragged cluster-scale data onto one machine. Executor OOM kills workers — the JVMs running tasks — usually because one partition holds 40% of the data (skew), partitions number in the dozens for terabytes, or a shuffle materializes giants. Raising the wrong memory pool is the classic expensive no-op.

The economics punish guessing. Doubling executor memory across 50 nodes costs 50x the RAM while the skewed task still holds its hot key; doubling driver memory for a skewed join fixes nothing while the bill climbs. Classification is cheap — the Spark UI's stage detail names the victim (driver stderr versus failed tasks) and the task histogram names the shape (one giant versus many larges) — and it directs money exactly where it helps.

This guide builds the split-brain playbook: driver discipline (bounded takes, Parquet sinks, broadcast budgets), executor discipline (salting, repartitioning, file sizing), memory math per pool, and GC-log reading that separates churn from genuine growth. Adaptive execution gets honest credit for what it automates and clear limits where it stops.

Driver vs Executor: Read the Victim First

Spark runs two JVM roles with separate heaps and separate failure modes. The driver plans, coordinates, and collects — one JVM, modest heap (4-8G), killed by anything that centralizes data: collect, toPandas, takeOrdered on millions, giant broadcasts, or driver-side Python UDF accumulation. The executors execute — many JVMs, larger heaps, killed by anything that concentrates data on one of them: skewed keys, fat partitions, exploding joins, or cached DataFrames never unpersisted.

The UI names the victim in seconds. Driver OOM: the application dies with driver stderr naming heap space while completed stages show green executors — nothing failed distributedly because the coordinator fell over. Executor OOM: stages show red failed tasks with heap space in task stderr, often retrying 4 times (spark.task.maxFailures) before the job aborts. Same exception text, opposite funerals — read the stage colors before the logs.

This split dictates every subsequent action. Driver victims get result-size discipline and broadcast budgets; executor victims get skew analysis and partition math. Cross-applying wastes both time and money: 16G executors can't digest an 800GB hot key, and driver raises can't drain a skewed shuffle. The first minute of every heap incident belongs to victim identification, and the UI answers it faster than any log.

BASH
1
2
3
4
5
6
# Victim ID from the outside (YARN example)
# yarn logs -applicationId application_1710000000000_0042 | grep -B3 -A8 'java.lang.OutOfMemoryError' | head -n 40
# Driver corpse: driver stderr names collectResult / TorrentBroadcast
# Executor corpse: task stderr names shuffle read / deserialization
# In-app guard: never trust an unbounded action
# df.write.mode('overwrite').parquet('s3://out/x/')  # sink, not collect
📊 Production Insight
Two fleet-wide memory doublings ($1,400 extra) preceded one glance at the stage histogram — victim-first reading would have named skew in minute one.
🎯 Key Takeaway
Stage colors name the victim: dead driver with green tasks is centralization; red tasks are concentration — fix the matching side.

Driver Discipline: Stop Feeding the Chef

The driver coordinates; it must never hold data at cluster scale. collect() materializes every row in driver memory — 10M rows that executors handled effortlessly become a single 8GB driver list that kills a 4G heap instantly. toPandas() doubles the insult with Arrow conversion overhead. takeOrdered(n) on huge n sorts distributedly then ships everything to one machine. Each has a bounded twin: limit(n).collect() for inspection, write.parquet for sinks, approxQuantile for statistics.

Broadcasts are the stealth centralization. Broadcast joins copy the small side to every executor through the driver — a 2GB dimension forced past the 10MB auto threshold crushes driver memory during TorrentBroadcast, then punishes executors holding the copy. Measure dimensions before broadcasting (count plus avg row bytes), respect spark.sql.autoBroadcastJoinThreshold, and hint MERGE for large-side joins explicitly. Driver-side Python accumulation (appending batches to a list across foreach) dies the same death in slower motion.

Enforce with bans, not advice. Block df.collect() and toPandas() without limit() in review linters, cap driver memory at honest values (raising it masks centralization instead of fixing it), and alert on driver heap usage crossing 80%. A driver that stays thin is a coordinator doing its job; a driver that grows is a data sink wearing a coordinator's badge.

driver_safe.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
from pyspark.sql import functions as F
# BAD: entire result onto one machine
# rows = df.collect()
# pdf = df.toPandas()
# GOOD: inspect bounded, sink distributed
sample = df.limit(1000).collect()
df.write.mode("overwrite").parquet("s3://out/events/")
stats = df.approxQuantile("amount", [0.5, 0.95, 0.99], 0.01)
# Broadcast only measured-small dimensions
small = spark.read.parquet("s3://dim/country/")
print(small.count(), len(small.columns))  # verify < threshold first
📊 Production Insight
A toPandas on 12M rows killed a tuned 8G driver in 40 seconds — the Parquet-sink rewrite finished the same analysis in 6 minutes with driver heap under 2G.
🎯 Key Takeaway
Bound every driver action, sink distributed results to storage, and broadcast only measured-small dimensions.

Executor Skew: Salt the Hot Key

Skew means one key owns a wildly disproportionate share — the default uid with 40% of events, the null key every malformed row shares, the celebrity node in a graph. Hash partitioning sends each key to exactly one partition, so the hot key's 800GB lands on one executor regardless of cluster size. Adding nodes or memory can't help: the atomic unit (one key) exceeds the memory unit (one heap). Only splitting the key across buckets restores parallelism.

Salting spreads the hot key over N sub-keys: append a random bucket (0-31) to the hot side and explode the dimension side across all buckets, join on (key, bucket), then drop the salt. The 800GB fans out to 32 tasks of 25GB each — digestible, parallel, and fast (41 minutes to 3). Salt only the hot keys when dimensions are large (targeted salting via a hot-key list) to avoid exploding the entire join; salt everything when simplicity beats the extra shuffle.

Detect before detonating. The top-key share query (groupBy-count-orderBy-limit) belongs in every pipeline's pre-join gate — alert over 10% single-key share. The task histogram confirms live: max duration 10-50x the median with matching shuffle-read disparity. AQE's skew hints mitigate some cases automatically, but atomic hot keys at 40% sail through — salt explicitly and keep AQE as the second net, never the plan.

salt_join.pyPYTHON
1
2
3
4
5
6
7
8
9
from pyspark.sql import functions as F
N = 32
# Fact side: random bucket per row
salted = fact.withColumn("salt", (F.rand() * N).cast("int"))
# Dimension side: replicate each row across all buckets
exploded = dim.withColumn("salt", F.explode(F.array(*[F.lit(i) for i in range(N)])))
joined = salted.join(exploded, on=["uid", "salt"]).drop("salt")
# Targeted variant: salt ONLY the known hot key, plain-join the rest
# hot = fact.filter(F.col('uid') == HOT); cold = fact.filter(F.col('uid') != HOT)
📊 Production Insight
Salting 800GB across 32 buckets cut the max task from 41 minutes to 3 — the same cluster, same memory, 14x faster through parallelism alone.
🎯 Key Takeaway
Measure top-key share, salt hot keys across buckets, and gate every join on skew — memory can't split atomic keys.

Memory Math: Size Each Pool Deliberately

Driver and executor memory are independent budgets set for independent reasons. spark.driver.memory (4-8G typical) covers plans, broadcast assembly, and bounded results — size it to the largest legitimate collect plus broadcast headroom, then stop. spark.executor.memory (8-16G typical) covers task working sets — size it to measured peak partition size times 1.5, never to the hot key's fantasy size. spark.executor.memoryOverhead (10-25% extra) covers off-heap, shuffles, and Python workers — raise it for PySpark UDF-heavy stages where Python RSS, not JVM heap, is the killer.

Partition math precedes memory math. Target 128-256MB per partition: spark.sql.files.maxPartitionBytes for reads, repartition(N) or adaptive coalescing for shuffles, and spark.sql.shuffle.partitions (default 200, raise to 800-2000 for terabytes). Dozens of partitions over terabytes means gigabyte tasks no heap survives — fix the count before the bytes. Kryo serialization (spark.serializer=org.apache.spark.serializer.KryoSerializer) halves object graphs versus Java serialization for cached and shuffled data.

Cost discipline closes the loop. Record peak executor heap per stage from the UI's executor tab, size to peak-plus-half, and return unused memory (the 16G-to-8G rollback saved the incident's budget). Memory-optimized instances help genuine uniform largeness, never atomic skew — buy them for measured peaks, not for histograms with one giant. Every pool gets a number with a measurement behind it, reviewed quarterly as data grows.

BASH
1
2
3
4
5
6
7
8
9
# Deliberate pools: driver modest, executors measured, overhead for Python
# --conf spark.driver.memory=6g
# --conf spark.executor.memory=8g
# --conf spark.executor.memoryOverhead=2g
# --conf spark.sql.shuffle.partitions=800
# --conf spark.serializer=org.apache.spark.serializer.KryoSerializer
# --conf spark.sql.adaptive.enabled=true
# --conf spark.sql.adaptive.skewJoin.enabled=true
# Verify peaks per stage in Spark UI Executors tab before resizing
📊 Production Insight
The 16G-to-8G rollback after salting saved the full extra spend with zero performance loss — measured peaks beat anxious doubling every time.
🎯 Key Takeaway
Partition to 128-256MB first, size each pool to measured peak plus headroom, and spend on uniform largeness — never on skew.

GC Logs: Churn vs Growth in Ten Minutes

Garbage-collector logs separate allocation churn (fast young GC, healthy old gen) from genuine growth (old gen climbing monotonically). Enable -XX:+PrintGCDetails with -Xloggc on one executor, run the failing stage, and plot: sawtooth with full recovery means churn — too many temporary objects per row, usually Python UDFs or deserialization loops. Staircase without recovery means retention — cached DataFrames, leaked broadcasts, or accumulating driver lists.

Churn fixes attack object rates: replace Python UDFs with Spark SQL functions (10-100x fewer objects), prefer column pruning (select needed columns before shuffles), and enable Kryo to shrink serialized forms. Growth fixes attack retention: unpersist() DataFrames the moment their last action completes, destroy broadcast variables past their join, and cap driver-side accumulation with bounded structures. The log shape picks the weapon — never tune blind.

Operationalize the reading. Ship executor GC summaries to the metrics store per stage (young-GC count, full-GC time, old-gen after), alert on full-GC time exceeding 10% of task time, and require a GC note on any memory-increase PR. Engineers who read GC logs size with evidence; engineers who don't buy RAM with hope. The incident's postmortem added GC summaries to every nightly job — the next skew announced itself in old-gen growth two weeks before it could kill a task.

BASH
1
2
3
4
5
6
7
8
# Executor GC logging (one executor is enough to classify)
# --conf spark.executor.extraJavaOptions='-XX:+PrintGCDetails -Xloggc:/tmp/gc.log'
# Churn signature: young GC every seconds, old-gen flat
# grep -c 'GC (Allocation Failure)' /tmp/gc.log
# Growth signature: old-gen climbs without recovery
# grep 'Full GC' /tmp/gc.log | awk '{print $NF}' | tail -n 20
# Retention hygiene in code:
# df.unpersist()  # the moment its last action completes
💡Unpersist the moment you're done
Cached DataFrames hold heap until unpersisted — through every later stage. Call unpersist() right after the last action that needs the cache, and broadcast variables get the same treatment. Retention you schedule beats retention you discover in GC logs.
📊 Production Insight
GC summaries on the nightly job showed old-gen climbing 2 weeks before the hot key reached lethal share — the skew was visible in retention before it detonated in tasks.
🎯 Key Takeaway
Log GC on one executor, read churn versus growth from the shape, and attack rates or retention accordingly.

A Repeatable Heap-OOM Workflow

Run every heap death through victim-shape-cause in order. Victim: stage colors plus stderr location — driver corpse with green tasks is centralization, red failed tasks are concentration. Shape: task histogram and top-key share — one 41-minute giant is skew, uniform largeness is sizing, driver collect frames are result abuse. Cause: the matching evidence — schema of the hot key, broadcast sizes, GC shape, partition counts.

Fix the cause's layer only. Centralization gets bounded actions and Parquet sinks. Skew gets salting plus backfill repair. Sizing gets partition math then measured pools. Retention gets unpersist and broadcast lifecycle. Then encode: skew gates on joins, collect linters in review, GC summaries per nightly job, memory numbers with measurements attached. Fixes without gates are anecdotes; gates make them policy.

Close with cost honesty. Record DBU and instance spend before and after — the incident's rollback documented $1,400 of waste reversed, which funded the observability work. Heap incidents recur as data grows, so the workflow's product is a team that classifies in minutes, sizes from measurements, and never doubles memory fleet-wide on a histogram with one giant again.

BASH
1
2
3
4
5
6
7
# 1. Victim: who died?
# yarn logs -applicationId <id> | grep -B3 -A8 'OutOfMemoryError' | head -n 40
# 2. Shape: skew or size? (notebook)
# df.groupBy('uid').count().orderBy(desc('count')).limit(5).show()
# 3. Cause: partitions and peaks (Spark UI: Stages + Executors tabs)
# 4. Fix layer only: salt | bound actions | repartition | unpersist
# 5. Encode: skew gate + collect lint + GC summaries + measured pools
⚠ Never double memory fleet-wide on a skewed histogram
One giant task beside hundreds of fast ones is atomic skew — extra gigabytes only delay its death while multiplying the bill. Salt the key first; size pools from measured peaks after the shape is healthy.
📊 Production Insight
The workflow now classifies heap deaths in under 10 minutes — victim, shape, cause — and the skew gate has blocked 4 bad backfills before they reached the join.
🎯 Key Takeaway
Victim, shape, cause, layer-matched fix, encoded gates — and cost receipts proving measurement beats doubling.
● Production incidentPOST-MORTEMseverity: high

One Hot Key Held 40 Percent of a 2TB Join

Symptom
The nightly identity join OOMed on executors 5 nights straight, dying 3-4 hours in with Java heap space on stage 7's shuffle read. The Spark UI showed 199 tasks finishing in under 90 seconds and one task running 41 minutes before dying — every night the same shape. The team raised executor memory from 8G to 16G on night 2, and the giant task survived 12 minutes longer before dying identically. DBU burn tripled with zero output rows.
Assumption
The team assumed uniform under-sizing because all tasks shared one stage — so they doubled memory fleet-wide twice, spending an extra $1,400 with no output. Then they blamed the instance type and moved to memory-optimized nodes, which stretched survival 20 more minutes. The actual shape was pure skew: a default uid value (from a botched backfill) owned 800GB of 2TB, and one task received nearly half the shuffle while 199 split the rest.
Root cause
A backfill bug wrote a default uid into 40% of events, creating an atomic hot key no partitioner can split — all 800GB hashed to one partition on one executor. That task's heap filled with its shuffle block plus deserialized objects while siblings idled; 16G versus 8G only delayed the inevitable because the key's data exceeded any single heap. Memory sizing can't fix atomic skew: the unit of parallelism (one key) exceeded the unit of memory (one heap).
Fix
The hot key was salted across 32 buckets (salted join against an exploded dimension copy) while the backfill was repaired to stop minting defaults. Max task dropped from 41 minutes to 3, executor memory returned to 8G, and the join finished in 47 minutes nightly. A skew check (top-key share alert over 10%) now gates the job, and AQE skew hints were enabled as a second net — salting remains the primary fix.
Key lesson
  • Read the task histogram before the memory settings — one giant task beside hundreds of fast ones is skew, never sizing.
  • Atomic hot keys defeat all memory upgrades; salt the key so parallelism, not heap, absorbs the volume.
  • Gate joins with top-key share alerts — skew grows silently from backfill bugs until it owns 40% of your data.
Production debug guideFive checks that split driver from executor and size the fix.5 entries
Symptom · 01
Driver dies with heap space around collect/toPandas/broadcast
→
Fix
Confirm driver-side: the driver stderr names collectResult or TorrentBroadcast while executors stay green. Replace df.collect() with df.limit(1000).collect() for inspection and df.write.parquet(path) for sinks. Fix: cap driver memory honestly (4-8G) and ban unbounded collects in review — the driver is a coordinator, not a data sink.
Symptom · 02
One executor task runs 10-50x longer, then OOMs
→
Fix
Open the stage detail and sort by Duration and Shuffle Read — one giant beside hundreds of fast tasks proves skew. Quantify with df.groupBy('uid').count().orderBy(desc('count')).limit(5). Fix: salt the hot key across 16-32 buckets; repartition only after salting.
Symptom · 03
All tasks uniformly large and slow before OOM
→
Fix
Check input file counts and partition numbers — dozens of partitions over terabytes means each task is genuinely huge. Fix: raise spark.sql.files.maxPartitionBytes discipline (smaller files) and repartition(400+) up front; then size executor memory to measured peak plus 50% headroom.
Symptom · 04
Frequent full GCs with low heap recovery
→
Fix
Enable -XX:+PrintGCDetails -Xloggc:/tmp/gc.log and compare young-GC frequency versus old-gen growth — churn shows rapid young GC, leaks show old-gen climbing monotonically. Fix churn with fewer objects (Kryo, smaller rows); fix leaks by unpersisting cached DataFrames (df.unpersist()) and closing broadcast lifecycles.
Symptom · 05
Broadcast join OOMs the driver or executors
→
Fix
Check the broadcast size in the UI's SQL tab versus spark.sql.autoBroadcastJoinThreshold (default 10MB) — a 2GB dimension forced to broadcast kills someone. Fix: disable auto-broadcast for that join (hint MERGE or SHUFFLE_HASH) or salt-split; broadcast only dimensions measured under the threshold.
Spark heap-OOM causes compared
Root CauseHow to ConfirmFixPrevention
Driver collect/toPandas/broadcast abuseDriver stderr names collectResult/TorrentBroadcast; tasks greenBound takes; Parquet sinks; measured broadcastsCollect linters; driver heap alerts at 80%
Skewed hot key in join/shuffleOne giant task; top-key share over 10%Salt 16-32 buckets; repair source backfillSkew gates on joins; AQE hints as second net
Too few partitions for the bytesUniform giant tasks; dozens of partitions over TBsRepartition to 128-256MB; raise shuffle partitionsPartition math in design review; file-size discipline
Retained cache/broadcast growthOld-gen staircase in GC logs; unpersist missingunpersist after last action; destroy stale broadcastsCache lifecycle review; GC summaries per job
Python UDF object churnRapid young GC; Python RSS leads JVM heapSQL functions over UDFs; Kryo; prune columnsUDF budget in review; GC-time alerts per stage
⚙ Quick Reference
2 commands from this guide
FileCommand / CodePurpose
driver_safe.pyfrom pyspark.sql import functions as FDriver Discipline
salt_join.pyfrom pyspark.sql import functions as FExecutor Skew

Key takeaways

1
Driver OOM is centralization
bound actions, sink to storage, budget broadcasts.
2
Executor OOM is concentration
salt hot keys, fix partitions, release retention.
3
Read victim then shape before spending
stage colors plus task histograms decide.
4
Size each pool to measured peaks after the shape is healthy
never before.
5
Separate churn from growth with GC logs; attack rates or retention accordingly.
6
Encode gates (skew checks, collect lints, GC summaries) so fixes become policy.

Common mistakes to avoid

6 patterns
×

Raising executor memory for a skewed join

Symptom
Giant task survives minutes longer then dies identically; bill multiplies with zero output
Fix
Salt the hot key so parallelism absorbs volume; size memory to healthy peaks after
×

Collecting cluster-scale results to the driver

Symptom
Driver dies in seconds while executors idle green; tuned heaps fall identically
Fix
Sink to Parquet; inspect with limit(n); compute stats distributedly
×

Broadcasting unmeasured dimensions

Symptom
TorrentBroadcast kills driver or executors on the same join that ran fine last month
Fix
Measure count and bytes first; hint MERGE past the threshold; respect autoBroadcastJoinThreshold
×

Caching without unpersisting

Symptom
Old-gen staircase across stages; later stages OOM on data that fit yesterday
Fix
unpersist() the moment the last action completes; treat cache as a loan, not storage
×

Treating AQE as skew insurance

Symptom
Atomic 40% hot keys sail through optimization and detonate at 3 AM regardless
Fix
Salt explicitly and gate top-key share; credit AQE as a second net, never the plan
×

Uniform task sizing by gut feel

Symptom
Gigabyte tasks from tiny partition counts; every stage runs hot with no skew in sight
Fix
Do partition math first (128-256MB targets), then size pools to measured peak plus 50%
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
How do you split driver from executor OOM?
Q02JUNIOR
Why can't memory fix a hot key?
Q03SENIOR
When is raising memory the right call?
Q04SENIOR
How do GC logs change the fix?
Q05SENIOR
Design a skew-proof nightly join.
Q01 of 05JUNIOR

How do you split driver from executor OOM?

ANSWER
Dead driver with green tasks means centralization (collect/broadcast); red failed tasks mean concentration (skew/partitions). Same text, opposite fixes — read stage colors first.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
How big should partitions be?
02
Does AQE make salting obsolete?
03
collect() works on small data — what's the harm?
04
Why did memory-optimized nodes not help?
05
How do Python UDFs cause JVM OOMs?
06
What belongs on a Spark memory dashboard?
N
Naren Founder & Principal Engineer

20+ years shipping production ML systems and the infrastructure behind them. Drawn from code that ran under real load.

Follow
✓ Verified
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
🔥

That's Tools. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
PySpark Py4JJavaError Fix
14 / 14 · Tools
Next
Sklearn NotFittedError Fix
→