PySpark / Performance Fig_06  ·  Lesson 06 / 14
Fig_06 · Cache and persist · v1.0 OSS Spark + Azure Databricks

Cache and persist

Storage levels · when to reuse, when not to

Spark is lazy: by default it recomputes a DataFrame from scratch on every action. cache and persist pin the computed result so later actions reuse it instead of re-running the whole lineage. This lesson is about where those blocks land, why the first action pays, and when caching hurts instead of helps.

Spark 3.2+ / DBR LTS RDD default MEMORY_ONLY DataFrame default MEMORY_AND_DISK verified Jun 2026

01 · What it is

Cook it once, keep it in the fridge

By default a DataFrame holds only a recipe — its lineage of transformations — not its rows. Every action (count, write, collect) re-runs that recipe from the source. Caching stores the computed partitions so the next action reads them instead of recomputing. Three verbs are all you need:

analogy An uncached DataFrame is a recipe card: every time you want the dish, you cook it from raw ingredients. Caching is cooking it once and putting it in the fridge — later you just reheat. The catch is fridge space (memory) is finite, and stashing food you'll never eat (a read-once DataFrame) just wastes the shelf.

The one rule to remember cache() == persist() with the default level. The whole game below is where the stored copy lives and what shape it's in — the §03 deep dive is the one place all of that is spelled out.
What caching does

Stores a DataFrame's computed partitions once, so later actions reuse them instead of re-running the whole lineage.

Cache when

The same DataFrame feeds ≥ 2 actions — and you'll unpersist() it when done.

Don't cache when

It's read once, or memory is tight — caching is then pure overhead and can evict execution memory.

02 · Why it matters

Recompute is the default — and it's expensive

use case You clean and join a 500 GB events table once, then run 6 aggregations off it for a dashboard build — cache the cleaned DataFrame so the expensive join runs once, not six times.

03 · How it works — deep dive

Lazy lineage, storage levels, and the first action that pays

A DataFrame's transformations are lazy — they only extend the plan, which runs from the source on each action. persist() inserts a checkpoint: the first action computes and stores the partitions; later actions read the stored copy. That is the entire idea. Everything below — the levels, the defaults, the lazy tax, the cleanup — is explained here once; later sections show it, verify it, and help you decide, but don't re-explain it.

3.1 · The storage levels, and where blocks land

A StorageLevel combines a few independent flags: memory? disk? off-heap?, serialized?, and a replication count. Suffix _2 replicates each block to a second node (_3 exists for disk).

The same five levels, as a mechanism map — where each block lands and in what shape:

LevelWhere blocks landSerialized?Replicated?
MEMORY_ONLY (RDD default)RAM; missing partitions recomputeNo (JVM)_2 available
MEMORY_AND_DISK (DataFrame default)RAM first, overflow spills to local diskNo (JVM)_2 available
DISK_ONLYLocal disk onlyYes_2 / _3 available
OFF_HEAP (experimental)Off-heap, outside the JVM (no GC)YesNo _2 variant
_SER levels (Scala/Java only)RAM / disk as a byte arrayYes_2 available

PySpark objects are always Pickle-serialized, so the _SER levels aren't separately exposed there; DISK_ONLY_3 does exist in Python.

RDD cache() ≠ DataFrame cache() The single most-confused fact on this topic — the default level differs by object. RDD cache() = MEMORY_ONLY (missing partitions recompute). DataFrame cache() = MEMORY_AND_DISK (missing partitions spill to disk). Quote the right one for the right object — interviewers test exactly this.

3.2 · The PySpark serialization nuance (the interview trap)

interview trap Doc-quoted: "In Python, stored objects will always be serialized with the Pickle library, so it does not matter whether you choose a serialized level." So PySpark does not separately expose the _SER levels — the Python-visible set is MEMORY_ONLY(_2), MEMORY_AND_DISK(_2), DISK_ONLY(_2/_3), and OFF_HEAP. (DISK_ONLY_3 does exist in Python.) Asking for MEMORY_ONLY_SER in PySpark is a no-op concept.

3.3 · Lazy materialization — the first action pays

3.4 · unpersist() — eager cleanup, and the storage-vs-GC trade-off

Reading it in the plan / UI A persisted DataFrame shows an InMemoryTableScan over an InMemoryRelation in .explain(mode="formatted") — proof the cache is used (the join/scan subtree is gone). The Spark UI Storage tab lists each cached DataFrame, its level, size in memory / on disk, and Fraction Cached (< 100% means it didn't all fit).

Figure A · where the blocks land

Pick a level, watch the blocks settle

Pick a base level, then toggle serialized, replicated (_2), and off-heap. Watch where the cached blocks land — RAM, local disk, or off-heap — the resolved level name, and what happens if a block doesn't fit. This figure isolates one thing: the shape and location of the stored copy. (Where it runs across the cluster is Figure B.)

StorageLevel explorer

One cached DataFrame of 8 partitions; the level decides where each block sits and in what shape.

serialized (_SER) replicated (_2) off-heap
JVM heap — RAM
Local disk
Off-heap (no GC)
—
resolved level
—
serialized?
—
replicas
—
if it doesn't fit

Simplified illustration — in PySpark objects are always Pickle-serialized, so the _SER levels aren't separately exposed; the serialized toggle explains the Scala/Java matrix interviewers ask about.

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 Before every cache()/persist() below, ask one question: "Is this DataFrame reused across multiple actions — and will I unpersist() it?" If it's read once, or you'll never free it, don't cache.

Cache a reused DataFrame and confirm it's used

pythonfrom pyspark.storagelevel import StorageLevel

# expensive: a wide join we don't want to recompute on every downstream action
clean = (events.join(devices, "device_id")        # the costly shuffle/join
                .filter("event_ts > '2026-01-01'"))

clean.persist(StorageLevel.MEMORY_AND_DISK)        # pick the level explicitly
clean.count()                                      # FIRST action: materialize the cache (slow once)

# ...now several downstream actions reuse the stored blocks instead of re-joining:
a = clean.groupBy("country").count()
b = clean.where("is_fraud").count()

# VERIFY — a.explain(mode="formatted") reads from the cache, not the join:
#   +- InMemoryTableScan [country#..]            <-- reading the CACHE
#         +- InMemoryRelation [..], StorageLevel(disk, memory, deserialized, 1 replicas)
#   (no SortMergeJoin / no FileScan events here  <-- the join is NOT re-run)

Spark UI → Storage tab: one cached entry, level Disk Memory Deserialized 1x Replicated, Fraction Cached 100%. If it's < 100%, the cache didn't all fit.

Choose a non-default level (too big for memory)

python# The cleaned DataFrame is 2× cluster RAM — don't fight for heap; cache to disk only.
huge.persist(StorageLevel.DISK_ONLY)   # PySpark also exposes DISK_ONLY_2 / DISK_ONLY_3
huge.count()                           # materialize

# VERIFY — Storage tab: Size in Memory = 0, Size on Disk > 0, level "Disk Serialized 1x".

Always unpersist when done

pythonclean.unpersist()        # eager: releases Storage memory for execution / other caches

# VERIFY — the Storage tab entry disappears; later .explain() falls back to the source plan.

Contrast: read-once (don't cache) vs reused (do cache)

python# ANTI-PATTERN: caching a DataFrame used by exactly ONE action — pure overhead.
once = big.filter("active").cache()
once.write.format("noop").mode("overwrite").save()   # only action → cache cost for nothing

# RIGHT: cache only when there are ≥ 2 actions, and unpersist after the last one.
reused = big.filter("active")
reused.persist(StorageLevel.MEMORY_AND_DISK); reused.count()       # materialize once
m1 = reused.groupBy("region").count(); m2 = reused.where("vip").count()  # 2 reuses → pays off
reused.unpersist()

# VERIFY — Spark UI SQL tab: the reused query shows a cached scan node;
# the source stages disappear on the second action.

Figure B · materialize once, reuse many

See it run across the cluster

This figure isolates a different thing from Figure A: not the shape of a block, but what happens across the nodes on each action. Step persist() then two actions across 1 driver + 3 executors, each holding its own block manager. The first action computes and stores the cached blocks; with a replicated level a copy is placed on a peer; the second action reuses them — no recompute. Use Prev / Play / Next.

persist → materialize → reuse

A 4-partition cleaned join spread across 3 executors; each cached block ≈ 240 MB.

Driver main()

driver heap0 MB

Executor 1 P0·P3

Executor 2 P1

Executor 3 P2

step 1 / 5
PySpark — dummy code
Block manager state

Simplified illustration — partition sizes, node counts, and heap figures are illustrative.

05 · Decision table

Which level, and what it costs you

Read this by the situation you're in — it answers "which level, and what's the failure mode?" at a glance. (The why is §3; this is the which. The _SER row is Scala/Java only — PySpark always serializes.)

Storage levelMemoryDiskSerialized (JVM)If it doesn't fitReach for it when
MEMORY_ONLY (RDD default)YesNoNorecompute missingRDDs; DataFrames prefer the safer default
MEMORY_AND_DISK (DataFrame default)YesspillNospill to diskThe everyday default — safe reuse
DISK_ONLYNoYesYesn/a (all on disk)Too big for RAM; zero heap/GC pressure
MEMORY_AND_DISK_2 / DISK_ONLY_2Yes / NoYes—spill / diskFault tolerance — survive an executor loss
OFF_HEAP (experimental)off-heapNoYesrecomputeCut GC on the cache; needs off-heap enabled
MEMORY_ONLY_SER (not in PySpark)YesNoYesrecomputeScala/Java: smaller heap, CPU to deserialize

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of §3:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
Quoting the RDD default for a DataFrameThe defaults differ by object, so the answer is wrong.RDD cache() = MEMORY_ONLY; DataFrame cache() = MEMORY_AND_DISK.
Expecting cache() to be eagerIt's lazy — nothing is stored until an action runs.Force it with a count() or a noop write.
Never calling unpersist()Stale blocks pin Storage memory and pressure GC for the rest of the session.Unpersist eagerly once the last action is done.
Caching everything "to be safe"Over-caching evicts execution memory and slows the job.The right default is not to cache — reserve it for ≥ 2 reuses.
Asking for MEMORY_ONLY_SER in PySparkIt isn't separately exposed — a no-op concept in Python.Python always serializes (Pickle); pick a memory/disk/off-heap level.

08 · References

Sources