PySpark / Performance Fig_03  ·  Lesson 03 / 14
Fig_03 · Driver memory · v1.0 OSS Spark + Azure Databricks

Driver memory

Driver OOM · protecting the one fixed heap

There are many executors but only one driver — a single fixed heap. One oversized collect(), one over-broadcast table, or one partition-count explosion can sink a job that 200 healthy executors were running perfectly. This lesson is about protecting that one node.

Spark 3.2+ / DBR LTS driver.memory 1g maxResultSize 1g verified Jun 2026

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:

The one rule to remember Executors scale out — add nodes for more data. The driver does not. Every time you move data to the driver, you are loading one fixed heap. Keep big data on the executors.
Driver's job

Plan the work, schedule tasks, collect small answers, and coordinate the application.

Healthy pattern

Executors process big data; the driver receives only small summaries or metadata.

Danger pattern

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

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 occupantPlain meaningWhy it can grow
Plan + scheduler stateThe driver's notebook, Spark plan, DAG scheduler, task metadata, and bookkeeping.Usually small, but many files, partitions, or tasks can make metadata huge.
Action resultsRows 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 sideThe "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.
A subtle second surface — the overhead region Think of driver memory as two buckets, not one:
  • spark.driver.memory is the driver's JVM heap — Spark plans, scheduler state, collected results, and broadcast-build data live here.
  • spark.driver.memoryOverhead is the driver's side bucket — off-heap memory, native libraries, Arrow, and the PySpark driver process live here.
Spark sizes the side bucket as 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.

What it protects

Serialized action results coming back to the driver, especially accidental large collect() calls.

What it does not add

It does not create more memory. Raising it only lets more data attempt to enter the same driver.

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.

PathWhat triggers itSymptom
(a) Big collect() / toPandas()Every partition serialized into one heapmaxResultSize abort, or a driver OutOfMemoryError if the cap was raised · doc-quoted
(b) Over-broadcastSmall side collected to the driver to build the broadcastDriver OOM during a join, before any executor sees the data · operational
(c) Partition / file explosion100,000s of tasks/files → per-partition metadata on the driverDriver OOM / GC-thrash during planning — no result collected · operational
(d) Large local PythonA giant list / pandas frame / model in driver codePressures the PySpark driver process (overhead, not JVM heap) · operational
Driver OOM ≠ executor OOM Driver OOM = data brought to the driver (paths a–d above). Executor OOM (Lesson 04) = a partition too big to process or spill on a worker. Different node, different log, different fix. This is the single distinction interviewers test most — hold it firmly.

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

collect(n) → driver heap

A fact table at ≈200 B/row, pulled to a 1g driver heap with a 1g maxResultSize guardrail.

—
result to driver
—
driver state
—
fits in 1g heap?

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.

Simple reading rule When you see code, ask one question first: "Does this move many rows to the driver, or keep them on executors?" If rows stay on executors, driver-OOM risk usually drops sharply.

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.

collect() vs write() across the nodes

A 4-partition fact table on 3 executors; each result partition ≈ 320 MB serialized.

Driver heap 1g

driver heap0 MB

Executor 1 P0

Executor 2 P1

Executor 3 P2·P3

step 1 / 5
PySpark — dummy code
Result partitions

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

ActionLands on the driver?Driver-OOM riskReach for it when
write.save / saveAsTableNo — executors writeNonePersisting any large result — the default
write.format("noop")NoNoneTiming / materializing a plan, no output
show(n)A capped previewLowEyeballing a few rows
take(n) / limit(n).collect()n rowsLowA small, bounded local sample
agg(...).collect()The summary (few rows)LowTotals/metrics computed on the cluster
collect()All rowsHighOnly a known-small result
toPandas()All rows + Python procHighOnly a known-small result
join(broadcast(dim))The small side (build)High if dim is bigdim is genuinely small (≤ ~10 MB)
Memory hook 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:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter 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 errorRemoves the clean abort and risks a real driver OOM.Keep a bounded cap and reduce what comes back.
Blaming executors for a broadcast OOMThe broadcast build happens on the driver first.Check the driver log and look for BroadcastExchange.
Bumping spark.driver.memory at runtimeDriver heap is a launch-time JVM setting.Set it in cluster config and restart.
toPandas() on a big DataFramePressures both JVM heap and PySpark/Python overhead.Aggregate, filter, sample, or write first.

08 · References

Sources