DE Learning

Spark Core · Module 1 · Production issues

Executor memory: failures and fixes

The memory failures you'll actually meet in production, and the ones interviewers love to ask about. Each one follows the same path: symptom, why, how to confirm, fix, prevention, and a short interview answer.

Start here: read the error

The first question is always which memory ran out. The error text tells you, and each region has different fixes. If you need a refresher on the regions, see the memory diagram.

What you seeMemory that ran outGo to
OutOfMemoryError: Java heap space or GC overhead limit exceeded in an executor logExecutor heapHeap OOM
Container killed … exceeding memory limits, OOMKilled, exit code 137Executor overhead (off-heap, Python)Container killed
OOM in the driver log, or bigger than spark.driver.maxResultSizeDriver heapDriver OOM
Not enough memory to build and broadcast the tableDriver and executor heap, via a broadcastBroadcast too big
No failure, but Spill (Memory / Disk) in the stage metricsExecution memory per taskHeavy spill
ExecutorLostFailure … heartbeat timed out, high GC timeHeap under GC pressure (or the node died)GC & lost executors
Python worker exited unexpectedly (crashed) during applyInPandasPython worker memoryPandas UDF OOM
Joins got slower after adding .cache()Storage crowding out executionCache pressure
The universal first answer
"I start with the exact error to tell driver from executor, and heap from container-killed. Then I check whether all tasks fail or only a few: a few means skew, all means partitions are too big for the memory each task gets. Only then do I change memory settings."

Executor heap OOM

Symptom

java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: GC overhead limit exceeded
Job aborted due to stage failure: Task 37 in stage 4.0 failed 4 times

Executors die, the stage retries, and after four failed attempts the job aborts.

Why it happens

One task needs more heap than its share (about pool ÷ cores) for something that can't spill:

  • Partitions far too large, for example 500 GB through the default 200 shuffle partitions is about 2.5 GB each.
  • A skewed key sends most rows to one task.
  • Unbounded groups: collect_list, collect_set or groupByKey on a key with millions of rows.
  • explode or a many-to-many join multiplying rows unexpectedly.
  • Very wide rows: large strings, JSON blobs, binary columns.

How to confirm

  1. Stages tab → failed tasks → read the error column.
  2. Only a few tasks fail? Compare max vs median Shuffle Read Size: that's skew.
  3. All tasks fail? Divide shuffle read by task count. Over ~1 GB per task means partitions are too big.
  4. SQL tab: look for a sudden jump in rows output after an explode or join.

Fix

  • Smaller partitions: raise spark.sql.shuffle.partitions, or lower AQE's advisory size and let it split.
  • Fix skew: AQE skew join, broadcast the small side, salt the hot key, filter NULL keys.
  • More memory per task: fewer cores per executor, or more executor memory.
  • Replace unbounded collect_list with a real aggregation, or cap it.
  • Drop unneeded wide columns before the shuffle.
spark.conf.set("spark.sql.shuffle.partitions", 2000)              # ~250 MB each for 500 GB
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128m")
# at cluster launch, if all else is tuned:
# spark.executor.cores=4   spark.executor.memory=24g

Prevent

  • Aim for 128–200 MB per shuffle partition; keep AQE on; watch max-vs-median task metrics on large jobs.
Say it in an interview
"Heap OOM means one task needed more than its share of executor memory for data that can't spill. If only a few tasks fail I treat it as skew; if all fail, partitions are too big. I fix the data distribution first, with more partitions or skew handling, and only then add memory or reduce cores per executor."

Container killed: overhead exceeded

Symptom

Container killed by YARN for exceeding memory limits.
18.1 GB of 18 GB physical memory used.
Consider boosting spark.executor.memoryOverhead.

# Kubernetes: executor pod status OOMKilled, exit code 137

The executor disappears with no OutOfMemoryError in its own log. The heap was fine; the process as a whole went over the container limit, so the cluster manager killed it.

Why it happens

  • Python workers for PySpark UDFs and pandas UDFs, one per running task, each with its own memory.
  • Arrow buffers moving data between the JVM and Python.
  • Native and off-heap memory: Netty shuffle buffers, compression libraries, Parquet/ORC readers.
  • Overhead left at the default 10%, which is sized for JVM-only jobs.

How to confirm

  1. The message comes from YARN or Kubernetes, not from the JVM.
  2. Executors tab: peak JVM heap used is well below spark.executor.memory.
  3. The failing stage runs Python UDFs, applyInPandas or very large shuffles.

Fix

  • Raise spark.executor.memoryOverhead, often to 15–25% of heap for PySpark-heavy jobs.
  • Set spark.executor.pyspark.memory to give Python workers an explicit budget, added to the container.
  • Fewer cores per executor means fewer Python workers running at once.
  • Replace Python UDFs with built-in functions; use pandas UDFs over plain Python UDFs.
  • Smaller Arrow batches: spark.sql.execution.arrow.maxRecordsPerBatch (10,000 by default).
spark.executor.memory=16g
spark.executor.memoryOverhead=4g        # instead of the default 1.6g
spark.executor.pyspark.memory=2g        # optional explicit Python budget

Prevent

  • Size overhead by workload type (JVM-only vs PySpark-heavy); prefer built-ins; keep a PySpark cluster profile with higher overhead.
Say it in an interview
"Container killed means the heap was fine but total process memory went over the container limit, usually Python workers or off-heap buffers. Adding heap makes it worse on a fixed node. I raise memoryOverhead or set pyspark.memory, reduce cores so fewer Python workers run at once, and move logic from Python UDFs to built-ins."

Driver OOM

Symptom

java.lang.OutOfMemoryError: Java heap space        (in the driver log)
Total size of serialized results of 412 tasks (1024.0 MiB)
is bigger than spark.driver.maxResultSize (1024.0 MiB)

The whole application dies, or a notebook detaches. Executors were healthy.

Why it happens

  • collect(), toPandas() or take(n) with a large n on big data.
  • Building a broadcast: by default the driver collects the small side first.
  • Planning over hundreds of thousands of files or partitions.
  • Very long query plans built in loops (each iteration extends the lineage).

How to confirm

  1. Find the last action in the driver log before the crash.
  2. Look for collect / toPandas / broadcast() on large data in that code path.
  3. Check file counts on the input tables.

Fix

  • Write results to a table or files instead of collecting; use limit() for samples.
  • Lower the broadcast threshold or remove a broadcast hint on a large table.
  • Compact small files; in loops, write intermediate results or checkpoint() to cut lineage.
  • Only then: raise spark.driver.memory and spark.driver.maxResultSize (default 1g).
# instead of
pdf = big_df.toPandas()
# do
big_df.write.mode("overwrite").saveAsTable("tmp.result")
sample = big_df.limit(1000).toPandas()

Prevent

  • Code-review rule: no collect or toPandas on unbounded data.
Say it in an interview
"Driver OOM almost always means data was pulled to the driver with collect or toPandas, a broadcast was too big, or planning hit too many files. I fix the cause, writing results out instead of collecting, and only raise driver memory as a last resort."

Broadcast too big

Symptom

Not enough memory to build and broadcast the table to all worker nodes.
As a workaround, you can either disable broadcast by setting
spark.sql.autoBroadcastJoinThreshold to -1 or increase the spark driver memory
by setting spark.driver.memory to a higher value.

Could not execute broadcast in 300 secs.
Cannot broadcast the table that is larger than 8GB.

Why it happens

  • The threshold was raised too high, or a broadcast() hint was put on a table that grew.
  • Wrong size estimates: missing or stale statistics make a large table look small.
  • On-disk size isn't memory size. Compressed Parquet often expands 5–10× in memory, so a 300 MB table can need 2–3 GB.
  • Every executor holds a full copy, so a big broadcast also squeezes executor memory.

How to confirm

  1. explain() shows BroadcastExchange on the table you expected to be large.
  2. SQL tab: the broadcast node's data size and build time.

Fix

  • Lower spark.sql.autoBroadcastJoinThreshold or remove the hint; let sort-merge join handle it.
  • Refresh statistics: ANALYZE TABLE … COMPUTE STATISTICS.
  • Rely on AQE to choose at runtime, using real sizes after filters.
  • If it's slow rather than too big, raise spark.sql.broadcastTimeout (300 s).

Prevent

  • Keep broadcasts to a few hundred MB in memory; don't blanket-raise the threshold cluster-wide.
Say it in an interview
"Broadcasts are collected on the driver and copied to every executor, and compressed files expand several times in memory. If a broadcast fails I check the real in-memory size, fix stale statistics, lower the threshold or drop the hint, and let AQE decide at runtime."

Heavy spill (slow, but no failure)

Symptom

The stage is slow. Its metrics show Spill (Memory) and Spill (Disk), often several times the shuffle read size.

Why it happens

A sort, join or aggregation needed more execution memory than its task had, so Spark wrote sorted runs to local disk and merged them later. Spill (Memory) is the in-memory size of what was spilled; Spill (Disk) is its serialized size on disk.

How to confirm

  1. Shuffle read ÷ tasks gives data per task. Far above 200 MB means partitions are too big.
  2. Spill spread evenly across tasks means partitions are too big; spill in a few tasks means skew.

Fix

  • More shuffle partitions, or a smaller AQE advisory size.
  • Fewer cores per executor, or memory-optimised instances.
  • Select fewer columns before the shuffle; aggregate before exploding.
  • Fast local disks (NVMe) for shuffle-heavy clusters.

The full walkthrough with example metrics is in Module 4: Disk spill scenario →

Say it in an interview
"Spill means a task's sort or aggregation didn't fit in its execution memory, so it used local disk. A little is fine. If it's large and even across tasks I add partitions or give each task more memory with fewer cores; if it's concentrated in a few tasks, it's skew."

Long GC pauses and lost executors

Symptom

ExecutorLostFailure (executor 7 exited caused by one of the running tasks)
Reason: Executor heartbeat timed out after 120000 ms

Tasks are slow and erratic; GC time in the Executors tab is a large share of task time (over about 10% is a warning).

Why it happens

  • Very large heaps full of objects cause long stop-the-world collections, and the executor misses its heartbeats.
  • Many cores sharing one heap create objects faster than GC clears them.
  • Caching large deserialized datasets fills the heap with long-lived objects.
  • Sometimes the node really is gone, for example a spot instance reclaimed.

How to confirm

  1. Executors tab: GC time against task time.
  2. Executor logs: GC log lines around the timeout.
  3. Cloud console: was the node pre-empted or terminated?

Fix

  • Smaller executors: around 4–5 cores and a moderate heap (often 16–32 GB) rather than one giant executor per node.
  • Cache less, or cache serialized (MEMORY_AND_DISK_SER), and unpersist when done.
  • Fix skew so no single task builds huge in-memory structures.
  • Raise spark.network.timeout only as a stopgap; it hides the problem.
  • For spot loss: graceful decommissioning (deep dive) and mixed on-demand/spot pools.
Say it in an interview
"A heartbeat timeout with high GC time means the executor was stuck in garbage collection. I'd move to smaller executors with fewer cores, cache less or cache serialized, and fix any skew, rather than just raising the network timeout."

Pandas UDF and applyInPandas OOM

Symptom

org.apache.spark.SparkException: Python worker exited unexpectedly (crashed)
# or the container is killed while a groupBy().applyInPandas() stage runs

Why it happens

groupBy().applyInPandas() loads all rows of one group into the Python worker as a single pandas DataFrame before calling your function. One huge group, often a skewed key, exhausts Python memory. pandas operations also make copies, so peak memory can be several times the data size.

How to confirm

  1. Count rows per group key: df.groupBy("key").count().orderBy(F.desc("count")).
  2. The failure happens in the stage running the pandas function, and the largest group dwarfs the median.

Fix

  • Make groups smaller: group by a finer key, or salt the key and combine the results.
  • Select only the columns the function needs, before the group.
  • Use built-in aggregations where possible; use mapInPandas for streaming over batches when you don't need whole groups.
  • Give Python more room: spark.executor.pyspark.memory, more overhead, fewer cores.
Say it in an interview
"applyInPandas materialises each whole group in one Python worker, so skewed groups cause OOM. I check group sizes, split or salt large groups, pass only needed columns, and give Python workers an explicit memory budget."

Cache pressure

Symptom

After adding .cache(), joins get slower and spill more. The Storage tab shows a Fraction Cached below 100%, and parts of the cached data are recomputed anyway.

Why it happens

  • Cache lives in the same unified pool as execution. Up to half the pool is protected for cache, so execution has less room.
  • With MEMORY_ONLY, partitions that don't fit are dropped and recomputed from lineage each time they're used.
  • Cached data that's never unpersisted keeps holding memory for the rest of the application.

Fix

  • Cache only DataFrames that are reused by several actions and expensive to recompute.
  • Cache after filtering and selecting columns, not the raw input.
  • Use MEMORY_AND_DISK (the DataFrame default) and call unpersist() when done.
  • For long pipelines, write an intermediate table instead of caching.
Say it in an interview
"Caching isn't free: it shares the unified pool with execution, so a large cache can make joins spill. I cache only reused, expensive DataFrames, after trimming them, and unpersist as soon as they're no longer needed."

Mitigation levers, cheat sheet

Roughly in the order to try them. Fixing the data distribution comes before buying memory.

LeverWhat it changesUse whenCost or risk
Fix skew (AQE skew join, broadcast, salting, filter NULL keys)Spreads hot keys across tasksA few tasks fail, spill or run longSalting adds code complexity
More shuffle partitions / smaller AQE advisory sizeLess data per taskHeap OOM or spill on all tasksToo many tiny tasks and small files if overdone
Fewer cores per executorMore memory per taskSpill or OOM on fixed hardwareLess parallelism per executor
Trim data early (select, filter, pre-aggregate)Smaller rows and fewer of themAlways worth checkingNone
More memoryOverhead / pyspark.memoryOff-heap and Python headroomContainer killed, PySpark jobsLess left for heap on a fixed node
More executor memoryBigger pool for every taskData is well distributed and still doesn't fitCost; very large heaps mean longer GC pauses
Avoid collect/toPandas; smaller broadcastsDriver memoryDriver OOMNone
Memory-optimised instancesMore memory per coreConsistently memory-bound workloadsHigher cost per core
Change spark.memory.fractionPool vs user memory splitRarely: heavy UDF objects in user memoryEasy to make things worse; last resort

Common interview questions

How do you troubleshoot an OOM in Spark?
Read the exact error: driver or executor, heap or container-killed. For executors, check if all tasks fail (partitions too big) or a few (skew), using max vs median shuffle read. Fix the data distribution first: more partitions, skew handling, trimming columns. Then adjust memory per task: fewer cores, more memory, or more overhead for PySpark. Re-run and confirm in the stage metrics.
What's the difference between "Java heap space" and "Container killed by YARN"?
Java heap space: the JVM heap (spark.executor.memory) ran out, so the JVM threw OutOfMemoryError. Container killed: the heap may be fine, but total process memory (heap + overhead: Python, off-heap, native) exceeded the container limit, so YARN or Kubernetes killed the process. The first needs smaller partitions or more heap; the second needs more overhead or less Python memory.
Your job OOMs. Would you just increase executor memory?
Not first. If the cause is skew, the hot task will still fail or barely fit. If it's container-killed, more heap makes it worse on a fixed node. I'd find the cause first and fix the distribution, and add memory only when the data is well spread and still doesn't fit. It's also the most expensive option.
Why can reducing cores per executor fix an OOM?
The unified pool is shared by the tasks running at once, one per core. Going from 8 to 4 cores doubles the memory each task can use, with no extra RAM. The cost is fewer parallel tasks per executor.
What uses executor memoryOverhead?
Everything outside the JVM heap: JVM metaspace and thread stacks, Netty network buffers for shuffle, native libraries such as compression codecs, and Python worker processes for PySpark (unless spark.executor.pyspark.memory is set). Default is max(384 MB, 10% of executor memory).
What's spark.memory.fraction, and should you tune it?
The share of (heap − 300 MB) used for the unified execution + storage pool, 0.6 by default; the rest is user memory. Rarely tune it. Lower it only if user code keeps large objects in memory; raising it leaves less room for those objects. Partitions, cores and skew are better levers.
What happens when execution memory runs out, and when storage memory runs out?
Execution first borrows free storage memory and evicts cache down to the protected half; if it still needs more, it spills to disk. Storage can only use free memory, so new cached blocks are dropped (MEMORY_ONLY, recomputed later) or written to disk (MEMORY_AND_DISK).
A broadcast join fails with "Not enough memory to build and broadcast the table". Options?
Check the real in-memory size (compressed files expand). Remove the hint or lower autoBroadcastJoinThreshold so Spark uses a sort-merge join; refresh table statistics; rely on AQE to decide at runtime. Increasing driver memory works only if the table genuinely should be broadcast.