Spark Memory Management and Debugging Failures
Read Spark's memory model, then reproduce and fix the three failures that actually take jobs down — driver OOM, executor OOM, and disk spill.
Spark failures are mostly memory failures, and they are legible once you know which pool ran out. This lesson reproduces each one.
The memory model
An executor’s JVM heap is divided:
┌─────────────────────────────────────────────────────────┐
│ Executor JVM heap (spark.executor.memory = 8g) │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Reserved (300 MB, fixed) │ │
│ └───────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Unified pool (spark.memory.fraction = 0.6) │ │
│ │ ┌─────────────────────┬──────────────────────┐ │ │
│ │ │ Storage │ Execution │ │ │
│ │ │ cached blocks │ shuffles, joins, │ │ │
│ │ │ broadcast vars │ sorts, aggregations │ │ │
│ │ │ (evictable) │ (not evictable) │ │ │
│ │ └─────────────────────┴──────────────────────┘ │ │
│ │ boundary moves — they borrow from each other│ │
│ └───────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ User memory (0.4) — your objects, UDF state │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
+ off-heap overhead (memoryOverhead), outside the heap
The asymmetry is the part that matters: execution can evict storage, storage cannot evict execution. A large shuffle will silently drop your cached DataFrame; a large cache will never starve a shuffle.
Check the actual numbers:
from pyspark.sql import SparkSession, functions as F
spark = (SparkSession.builder.appName("memory").master("local[2]")
.config("spark.driver.memory", "2g")
.config("spark.executor.memory", "2g")
.getOrCreate())
for k in ["spark.memory.fraction", "spark.memory.storageFraction",
"spark.executor.memory", "spark.driver.memory"]:
print(f"{k:32} {spark.conf.get(k, '(default)')}")
spark.memory.fraction 0.6
spark.memory.storageFraction 0.5
spark.executor.memory 2g
spark.driver.memory 2g
Failure 1: driver OOM from collect
big = spark.range(50_000_000).select(
F.col("id"), (F.rand() * 1000).alias("v"), F.concat(F.lit("row-"), F.col("id")).alias("label")
)
rows = big.collect()
25/02/15 12:04:18 ERROR TaskSetManager: Total size of serialized results of 8 tasks (2.1 GB)
is bigger than spark.driver.maxResultSize (1024.0 MB)
py4j.protocol.Py4JJavaError: An error occurred while calling o56.collectToPython.
: org.apache.spark.SparkException: Job aborted due to stage failure:
Total size of serialized results is bigger than spark.driver.maxResultSize
Spark caught this one before the heap actually blew, because maxResultSize defaults to 1 GB.
Raising it just moves the failure:
spark.conf.set("spark.driver.maxResultSize", "8g")
rows = big.collect()
java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3537)
at org.apache.spark.sql.Dataset.collectFromPlan(Dataset.scala:3715)
The fix is not more driver memory — it is not collecting:
print("count:", big.count()) # aggregate on executors
big.limit(5).show() # bounded
big.write.mode("overwrite").parquet("/tmp/big") # write from executors
count: 50000000
+---+------------------+--------+
| id| v| label|
+---+------------------+--------+
| 0| 412.331276160446| row-0 |
| 1| 887.204718641342| row-1 |
| 2| 91.556632900231| row-2 |
| 3| 233.901128845629| row-3 |
| 4| 764.331921830123| row-4 |
+---+------------------+--------+
toPandas() has the same problem and is worse, because it materialises the data twice — once
as JVM rows, once as Python objects. Use spark.sql.execution.arrow.pyspark.enabled to halve
that, and still only on small results.
Failure 2: executor OOM from a wide partition
skewed = spark.range(20_000_000).select(
F.lit(1).alias("key"), # every row shares one key
F.concat(F.lit("payload-"), F.col("id")).alias("payload"),
)
skewed.groupBy("key").agg(F.collect_list("payload")).count()
25/02/15 12:19:33 ERROR Executor: Exception in task 0.0 in stage 4.0 (TID 62)
java.lang.OutOfMemoryError: Java heap space
at org.apache.spark.sql.catalyst.expressions.codegen.BufferHolder.grow
at org.apache.spark.sql.catalyst.util.GenericArrayData
collect_list builds one array holding 20 million strings in a single executor’s heap. No
partition count or memory setting fixes this, because the result does not fit. Aggregate to
something bounded instead:
skewed.groupBy("key").agg(
F.count("*").alias("n"),
F.first("payload").alias("sample"),
).show()
+---+--------+---------+
|key| n| sample|
+---+--------+---------+
| 1|20000000|payload-0|
+---+--------+---------+
The rule: any aggregate whose output grows with input size — collect_list, collect_set,
a pivot with unbounded cardinality — will eventually fail on a large group.
Failure 3: spill
Spill is survivable but slow. Force it by shrinking partition count:
import time
data = spark.range(30_000_000).select(
(F.col("id") % 500_000).alias("key"), F.rand().alias("v")
)
for parts in (4, 64, 400):
spark.conf.set("spark.sql.shuffle.partitions", parts)
t0 = time.perf_counter()
data.groupBy("key").agg(F.avg("v")).count()
print(f"{parts:4} partitions: {time.perf_counter() - t0:6.2f}s")
4 partitions: 41.83s
64 partitions: 11.27s
400 partitions: 8.94s
With four partitions each task held 7.5 million rows, exceeded its execution memory, and wrote sorted runs to disk. The Spark UI shows it directly in the stage detail:
Metric Min Median Max
Duration 38 s 40 s 42 s
GC Time 4 s 5 s 6 s
Shuffle Read Size 180 MB 182 MB 184 MB
Spill (Memory) 2.1 GB 2.2 GB 2.3 GB
Spill (Disk) 610 MB 640 MB 668 MB
Non-zero Spill (Disk) is the signal. Fix it with more partitions before reaching for more memory — partitions are free, memory is not.
Cache eviction
cached = spark.range(20_000_000).select(F.col("id"), F.rand().alias("v")).cache()
cached.count()
print("cached partitions:", spark.sparkContext._jsc.sc().getRDDStorageInfo().length)
cached partitions: 1
Now run a shuffle-heavy job in the same session and check again:
data.groupBy("key").agg(F.avg("v")).count() # heavy shuffle
print(cached.storageLevel)
print("still cached fully?", cached.count())
Disk Memory Deserialized 1x Replicated
still cached fully? 20000000
The count is still right, but some blocks were evicted from memory and re-read from disk — the
MEMORY_AND_DISK level means correctness is preserved and only speed suffers. Watch the
Storage tab’s “Fraction Cached” to see this happening; a cache that drops below 100% during
heavy stages is execution memory reclaiming its space.
To protect a cache, raise spark.memory.storageFraction — but that shrinks what execution can
borrow, so it trades one failure mode for another.
Reading GC pressure
spark-submit \
--conf "spark.executor.extraJavaOptions=-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps" \
app.py ...
[GC (Allocation Failure) [PSYoungGen: 1398144K->174520K(1567744K)] 3947264K->3512392K(5138432K), 0.4821 s]
[Full GC (Ergonomics) [PSYoungGen: 174520K->0K(1567744K)] [ParOldGen: 3337872K->2891244K(3570688K)] 3512392K->2891244K(5138432K), 3.8214 s]
A 3.8-second full GC pause, repeated, means the heap is too full. In the UI, compare GC Time to Duration per task — above roughly 10% is a problem. The fixes, in order of preference:
- More partitions, so each task’s working set is smaller
- Less caching, or
MEMORY_AND_DISK_SERto store compactly - Fewer cores per executor, giving each task more heap
- Only then, more executor memory
A diagnostic table
| Symptom | Where | Cause | Fix |
|---|---|---|---|
maxResultSize exceeded | driver | collect() on large data | aggregate or write instead |
OutOfMemoryError in collectFromPlan | driver | same, without the guard | same |
OutOfMemoryError in GenericArrayData | executor | unbounded collect_list | bounded aggregate |
| Container killed by YARN | executor | off-heap overflow | raise memoryOverhead |
| Non-zero Spill (Disk) | executor | partitions too large | more partitions |
| GC time > 10% of duration | executor | heap pressure | more partitions, less cache |
| One task far slower than median | executor | skew | salt the key, or broadcast |
Practice
1. Call collect() on a 10-million-row DataFrame with a 1 GB driver.
Total size of serialized results of 8 tasks (1740.2 MB) is bigger than
spark.driver.maxResultSize (1024.0 MB)
Spark’s guard fired before the heap did. Treat maxResultSize as a safety rail, not a limit to
raise — the fact that you hit it means the design is wrong.
2. Run a groupBy with 4 and then 400 shuffle partitions, comparing Spill (Disk).
4 partitions: Spill (Disk) 640 MB, duration 41.8s
400 partitions: Spill (Disk) 0 B, duration 8.9s
Eliminating spill accounted for nearly all the speed-up. Partition count is almost always the first thing to change when a shuffle stage is slow.
3. Cache a DataFrame, then run a large shuffle. Check Fraction Cached.
before shuffle: 100% cached, 1.4 GB in memory
after shuffle: 62% cached, 0.9 GB in memory, 0.5 GB on disk
Execution memory evicted storage blocks. Nothing failed and no warning was printed — the cache just got slower. This is the clearest illustration of the one-way eviction rule.
4. Set spark.memory.fraction to 0.2 and run a shuffle-heavy job.
default (0.6): 8.94s, Spill (Disk) 0 B
lowered (0.2): 36.71s, Spill (Disk) 1.9 GB
Shrinking the unified pool starves execution and forces spill, while the 0.4 user-memory portion sits mostly unused. The default is well chosen — lower it only if your UDFs genuinely allocate large amounts of user memory.
That completes the Spark core track: architecture, RDDs, the SQL engine, deployment, and the memory model behind every failure you will meet.