01 · What it is
One node, one heap, one job to keep it small
Every Spark application has exactly one driver — the JVM that runs your notebook, holds the SparkSession, builds the plan, and schedules work onto the executors. Its heap (spark.driver.memory, default 1g) is fixed. A driver OOM is simply what happens when something you asked Spark to bring back to the driver is too big to fit.
analogy The driver is the air-traffic controller in the tower: it doesn't fly the planes — the executors do — it just tracks every flight. Order all the cargo delivered to the tower and you crush it, no matter how many planes you have.
A handful of things share that one heap — action results, the build side of a broadcast, and the plan/scheduling metadata. The deep dive below breaks down each and how it fails; for now just hold the shape of the problem:
Plan the work, schedule tasks, collect small answers, and coordinate the application.
Executors process big data; the driver receives only small summaries or metadata.
collect(), large broadcasts, or huge task metadata force too much onto one driver.
02 · Why it matters
The driver is the one thing you can't lose
- It's a single point of failure. A slow executor degrades one task; a dead driver kills the whole application — every executor's work is thrown away.
- It fails for non-obvious reasons. The data on the executors can be perfectly healthy while the driver dies — so driver problems are the ones that look mysterious.
- It's a classic interview probe: "A job ran fine for months, then started failing — with no growth in the executor-side data. Where do you look?" The answer is usually the driver: a result set, a broadcast, or a partition count that crept over the limit. The how is the next section.
03 · How it works — deep dive
What fills the driver heap, and how it tips over
Everything a driver OOM comes down to is data arriving at one fixed heap faster than it can hold. So the useful way to understand it is: (1) what legitimately lives on that heap, (2) the guardrail that's supposed to catch overflow, and (3) the specific paths that defeat it. Each idea below is explained here once — later sections show it, verify it, and help you decide, but don't re-explain it.
3.1 · What shares the one heap
Use this as the driver-memory map: if something lands in one of these rows and grows too large, the driver is the node under pressure.
| Driver heap occupant | Plain meaning | Why it can grow |
|---|---|---|
| Plan + scheduler state | The driver's notebook, Spark plan, DAG scheduler, task metadata, and bookkeeping. | Usually small, but many files, partitions, or tasks can make metadata huge. |
| Action results | Rows returned by collect(), take(n), head(n), or the JVM side of toPandas(). | Executors serialize results, then the driver deserializes them into one heap. |
| Broadcast build side | The "small" side of a broadcast join is first collected to the driver and built there. | If the broadcast side silently becomes large, the driver can OOM before executors receive it. |
spark.driver.memoryis the driver's JVM heap — Spark plans, scheduler state, collected results, and broadcast-build data live here.spark.driver.memoryOverheadis the driver's side bucket — off-heap memory, native libraries, Arrow, and the PySpark driver process live here.
driverMemory × memoryOverheadFactor, with a floor of spark.driver.minMemoryOverhead = 384m. The usual factor is 0.10 (10%). For non-JVM Kubernetes workloads, Spark uses 0.40 (40%) because Python/native processes often need much more memory outside the JVM heap.
Example: with a 10 GB driver, normal overhead is about 1 GB; non-JVM Kubernetes overhead is about 4 GB. Heavy local Python or
toPandas() can pressure this overhead bucket even when the JVM heap looks fine.
3.2 · spark.driver.maxResultSize — the guardrail
use case A tripwire so a stray collect() aborts cleanly with a clear error, instead of silently thrashing the heap into GC death.
Serialized action results coming back to the driver, especially accidental large collect() calls.
It does not create more memory. Raising it only lets more data attempt to enter the same driver.
- What it is (doc-quoted). "Limit of total size of serialized results of all partitions for each Spark action (e.g.
collect). Jobs will be aborted if the total size is above this limit. Having a high limit may cause out-of-memory errors in driver." Default 1g. - The mechanism. As task results stream back, Spark sums their serialized sizes; the instant the running total crosses the limit, the job is aborted — a pre-emptive stop, before the heap is necessarily exhausted.
- Why it's worth having. It turns a creeping, GC-thrashing driver OOM (slow, hard to diagnose) into a fast, explicit failure with an actionable message.
- The trap (Rung 3). Raising it — or setting
0for "unlimited" — removes the tripwire, so a too-bigcollect()sails past the clean abort into a realOutOfMemoryError. Raise it only after you've sizedspark.driver.memoryto match.
3.3 · The four paths to a driver OOM
Every real-world driver OOM is one of these four. This is the canonical list — the comparison table and gotchas later point back here rather than re-explaining.
| Path | What triggers it | Symptom |
|---|---|---|
(a) Big collect() / toPandas() | Every partition serialized into one heap | maxResultSize abort, or a driver OutOfMemoryError if the cap was raised · doc-quoted |
| (b) Over-broadcast | Small side collected to the driver to build the broadcast | Driver OOM during a join, before any executor sees the data · operational |
| (c) Partition / file explosion | 100,000s of tasks/files → per-partition metadata on the driver | Driver OOM / GC-thrash during planning — no result collected · operational |
| (d) Large local Python | A giant list / pandas frame / model in driver code | Pressures the PySpark driver process (overhead, not JVM heap) · operational |
Figure A · the tipping point
Watch the heap cross the line
Drag how many rows you collect() and watch the serialized result climb toward the maxResultSize line. This figure isolates one thing: where the threshold is — the clean abort, and what raising the cap turns it into. (Where the data physically travels is Figure B.)
A fact table at ≈200 B/row, pulled to a 1g driver heap with a 1g maxResultSize guardrail.
Simplified illustration — real heap headroom is under 1g (plan + scheduler share it) and row width varies.
04 · The code — and how to verify it
Four moves, each with its proof
Every technique below is paired with how to prove it worked — the plan node or Spark-UI signal. Apply, then verify; never assume.
Replace collect() with a write (or a bounded pull)
python# NAIVE: pulls EVERY row of a fact table into the 1g driver heap → abort or OOM.
rows = big_df.collect() # all partitions serialized into one heap
pdf = big_df.toPandas() # same hazard, plus the Python process
# RIGHT: keep the data on the executors — write it out…
big_df.write.mode("overwrite").saveAsTable("main.pyspark_perf.result")
# …or RUN the job for timing without returning rows, via the noop sink:
big_df.write.format("noop").mode("overwrite").save()
# Need a few rows locally? Pull a BOUNDED set, never the whole thing:
sample = big_df.limit(1000).collect() # at most 1000 rows reach the driver
# VERIFY — driver log on a too-big collect() shows:
# "Total size of serialized results ... is bigger than spark.driver.maxResultSize"
Aggregate on the cluster, then collect the small summary
pythonfrom pyspark.sql import functions as F
total_bad = sum(r["amount"] for r in big_df.collect()) # pulls every row — hazard
total_ok = big_df.agg(F.sum("amount")).collect()[0][0] # 1 row to the driver
# VERIFY — .explain() shows HashAggregate (partial + final) on the cluster;
# the final exchange is tiny, not the whole table.
Guard against a mis-sized broadcast (driver-side build)
pythonfrom pyspark.sql.functions import broadcast
risky = fact.join(broadcast(too_big_dim), "k") # small side built on the driver → OOM
ok = fact.join(small_dim, "k") # let the planner/AQE broadcast only if it fits
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 10*1024*1024) # 10 MB (OSS default)
# VERIFY — fact.join(...).explain(mode="formatted"): a BroadcastExchange on the SMALL
# side is fine; on a multi-GB side it's the warning sign. The OOM, if it comes, is on
# the DRIVER log, not an executor.
Size the two limits together, deliberately
pythonprint(spark.conf.get("spark.driver.memory")) # '1g' — the heap (launch-time)
print(spark.conf.get("spark.driver.maxResultSize")) # '1g' — the guardrail (runtime-settable)
spark.conf.set("spark.driver.maxResultSize", "2g") # raise the guardrail ONLY with a matching heap
# spark.driver.memory is a launch-time JVM flag → set it in the cluster config + restart:
# spark.driver.memory 8g
# VERIFY — Spark UI → Executors tab: the single 'driver' row shows memory + GC time.
# Rising driver GC time during a collect() = the heap is under pressure.
Figure B · where the rows go
See it run across the cluster
This figure isolates a different thing from Figure A: not the threshold, but where the data physically lands. Step a collect() across 1 driver + 3 executors and watch results pile onto the one heap — then flip to df.write and watch the driver stay flat. Use Prev / Play / Next.
A 4-partition fact table on 3 executors; each result partition ≈ 320 MB serialized.
Driver heap 1g
Executor 1 P0
Executor 2 P1
Executor 3 P2·P3
Simplified illustration — sizes are illustrative and the real heap shares space with plan + scheduler state.
05 · Decision table
Which action, and what it costs the driver
Read this by the action you're about to call — it answers "will this touch the driver?" at a glance. (The why is §3; this is the which.)
| Action | Lands on the driver? | Driver-OOM risk | Reach for it when |
|---|---|---|---|
write.save / saveAsTable | No — executors write | None | Persisting any large result — the default |
write.format("noop") | No | None | Timing / materializing a plan, no output |
show(n) | A capped preview | Low | Eyeballing a few rows |
take(n) / limit(n).collect() | n rows | Low | A small, bounded local sample |
agg(...).collect() | The summary (few rows) | Low | Totals/metrics computed on the cluster |
collect() | All rows | High | Only a known-small result |
toPandas() | All rows + Python proc | High | Only a known-small result |
join(broadcast(dim)) | The small side (build) | High if dim is big | dim is genuinely small (≤ ~10 MB) |
write keeps the big result distributed. show and take bring back a small sample. collect and toPandas ask the single driver to hold the result locally.
06 · Edge cases & limitations
Where it gets subtle
New nuance an interviewer probes — not a re-run of the four paths:
- The broadcast that's almost too big. A dimension at 8 MB today, 80 MB next quarter, silently starts OOMing the driver during the build. Cap the threshold; let AQE decide at runtime rather than forcing
broadcast(). toPandas()hits two meters at once. Every row lands in the JVM heap and is converted in the Python driver process (overhead) — it can OOM the container even when the JVM heap alone looks fine.- Raising the cap without raising the heap. Removing the guardrail doesn't add capacity — it just converts a clean abort into a slower, harder-to-diagnose OOM.
Hard limits worth stating plainly:
- The driver does not scale out. There is exactly one, one fixed heap — you can't "add driver nodes." Above the heap the only moves are bring less back or a bigger driver node.
spark.driver.memoryis launch-time. It's a JVM flag set when the driver starts — set it in the cluster Spark config (a restart), notspark.conf.set(...). OnlymaxResultSizeis runtime-settable.- Doc-grounding caveat. Only path (a) — the
collect()/large-result route viamaxResultSize— is verbatim in the docs; paths (b)–(d) are well-established operational practice, stated as such.
07 · Common mistakes
Gotchas — the mistake and its one-line fix
| Mistake | Why it hurts | Better move |
|---|---|---|
collect() "just to look" | Pulls all rows into the one driver heap. | Use show(20) or limit(20).collect(). |
maxResultSize=0 to silence the error | Removes the clean abort and risks a real driver OOM. | Keep a bounded cap and reduce what comes back. |
| Blaming executors for a broadcast OOM | The broadcast build happens on the driver first. | Check the driver log and look for BroadcastExchange. |
Bumping spark.driver.memory at runtime | Driver heap is a launch-time JVM setting. | Set it in cluster config and restart. |
toPandas() on a big DataFrame | Pressures both JVM heap and PySpark/Python overhead. | Aggregate, filter, sample, or write first. |
08 · References
Sources
- Apache Spark — Configuration (driver.memory, maxResultSize, memoryOverhead)
- Apache Spark — Cluster overview (driver, executors, what the driver does)
- Apache Spark — Tuning guide (memory management, serialized results)
- Apache Spark — SQL Performance Tuning (broadcast join, driver-side build)
- Azure Databricks — Compute configuration (the driver node)