Caching DataFrames for Iterative Workloads
Learn how to cache DataFrames in Databricks to speed up iterative workloads, with hands-on steps and troubleshooting tips.
Focus: cache dataframes for iterative workloads
You've just run the same aggregation five times in a row, tweaking a filter each time, and watched your Spark job re-read the same parquet files from cloud storage every single run. That's not just slow — it's expensive. When you're iterating on code in Databricks, querying the same data repeatedly without caching is like re-downloading the same movie trailer every time you want to watch it again. This lesson shows you how to cache DataFrames for iterative workloads, cutting runtime from minutes to seconds and saving you cloud credits while you're at it.
The problem this lesson solves
Iterative workloads — machine learning feature engineering, ad-hoc exploratory analysis, multi-stage ETL development — share a common pain point: you repeatedly compute the same transformations on the same base data. Without caching, every action on a DataFrame triggers a full recomputation from the source. That means:
- Re-reading parquet, Delta, or CSV files from object storage (S3, ADLS, GCS) on each query
- Re-executing expensive joins, filters, and aggregations from scratch
- Longer notebook runtimes that kill your flow and your budget
- Submit-to-slot delays while your cluster re-fetches data across the network
The worst part? Spark's lazy evaluation means a DataFrame is just a recipe until an action runs. Every count(), show(), or write triggers the whole lineage. As you iterate, you're paying the full scan cost for every tweak — even if only the last step changed.
Real-world impact: In a typical ad-hoc analysis session, caching the base DataFrame can cut total notebook time by 40–70%. For ML feature engineering across hundreds of iterations, the savings compound drastically.
By the end of this lesson, you'll be able to explain the core idea behind caching DataFrames for iterative workloads and complete a practical exercise that uses caching effectively — exactly what you need to keep your Databricks workflows fast and cost-efficient.
Core concept / mental model
Think of a DataFrame as a recipe, not the dish itself. Spark's lazy evaluation builds a lineage graph — the steps to create the DataFrame — but stores nothing until an action forces execution. Caching is like snap-freezing the finished dish after the first cook: you can reheat (reuse) it instantly instead of cooking from scratch every time.
Here's how it maps to Spark components:
- DataFrame — the recipe (logical plan)
- Action (e.g.,
count(),show()) — the chef that executes the recipe - Cache — an in-memory (and/or disk) snapshot of the dish, stored in the cluster's executors
When you call df.cache(), you instruct Spark to mark that DataFrame for caching. The actual caching happens lazily — the first time you run an action on it, Spark computes the result and stores it in the executors' memory (and optionally spill to disk). Subsequent actions on the same DataFrame or its cached descendants hit the cache instead of recomputing.
Mental model in one line:
cache()= "save this result after the first time you compute it, and reuse it for every subsequent request."
Key words to remember:
- Lazy — cache() doesn't trigger computation by itself
- In-memory — fastest access, limited by cluster RAM
- Recomputation vs. reuse — the difference between slow iteration and fast iteration
How it works step by step
The caching process in Databricks follows a predictable flow. Here's the step-by-step progression you'll internalize:
- Define your base DataFrame — load the source data (parquet, Delta, CSV) and apply only the transformations that are reused across your iterations (e.g., filtering to relevant columns, joining dimension tables).
- Invoke
.cache()on that DataFrame — this marks it for caching in the Spark plan. - Trigger an action — call something like
df.count()ordf.writeto force Spark to compute and cache the DataFrame. Without an action, caching is a no-op. - Reuse the cached DataFrame — for subsequent actions (e.g.,
df.where(...).count()), Spark reads from executors' memory instead of re-reading from source. - Release the cache when done — call
df.unpersist()to free memory and avoid unnecessary storage overhead.
The key insight: caching is most effective when the cached DataFrame is reused multiple times. If you only query data once, caching adds overhead without benefit — the cost of serializing and storing exceeds any savings.
Here's what happens under the hood:
- Spark stores the cached data as a columnar format in JVM memory (or off-heap, depending on storage level).
- Default storage level is MEMORY_AND_DISK — data spills to disk if it doesn't fit in memory.
- When you run an action on a cached DataFrame, Spark's query planner detects the cached partition and skips recomputation.
- Delta cache (also called Delta Log caching) is a different, faster mechanism at file-level; we'll contrast them in the compare section.
Hands-on walkthrough
Let's put this into practice with a realistic iterative scenario: you're exploring a sales dataset and want to test several filter conditions.
Setup
First, create a base DataFrame from a Delta table:
# In a Databricks notebook
from pyspark.sql.functions import col
# Load base data (this reads from cloud storage — expensive if repeated)
sales_df = spark.read.format("delta").load("/mnt/datalake/sales")
# Add a reusable transformation: filter to current year and select needed columns
base_df = sales_df.filter(col("year") == 2024).select("customer_id", "amount", "region")
Now, this base_df is just a recipe. Run a quick action to see how slow it is without caching:
import time
start = time.time()
print(f"Sales count: {base_df.count()}")
print(f"First query took {time.time() - start:.2f} seconds")
Output (example):
Sales count: 1234567
First query took 12.3 seconds
Without caching — the pain
Now run the same count again (as if re-running a cell during iteration):
start = time.time()
print(f"Second count: {base_df.count()}")
print(f"Second query took {time.time() - start:.2f} seconds")
Output (example):
Second count: 1234567
Second query took 11.8 seconds
It's equally slow — Spark re-read the entire Delta table. Now let's fix that.
With caching — the fix
Caching the DataFrame flips the story:
from pyspark import StorageLevel
# Mark the DataFrame for caching
base_df.cache()
# First action: this computes AND caches
t1 = time.time()
print(f"Cached count: {base_df.count()}")
print(f"First cached query took {time.time() - t1:.2f} seconds")
# Subsequent action: reads from cache
t2 = time.time()
print(f"Second cached count: {base_df.count()}")
print(f"Second cached query took {time.time() - t2:.2f} seconds")
Output (example):
Cached count: 1234567
First cached query took 12.5 seconds (includes caching overhead)
Second cached query took 0.08 seconds
That's over 100× faster for subsequent queries. Now you can iterate through your analysis freely:
# Iterative exploration — each runs blazing fast
for region in ["North", "South", "East"]:
count = base_df.filter(col("region") == region).count()
print(f"{region} sales: {count}")
# Aggregate across the cached data
base_df.groupBy("region").sum("amount").show()
Output (example):
North sales: 321123
South sales: 456789
East sales: 456655
+------+----------+
|region| sum(amount)|
+------+----------+
| North| 12345678.0|
| South| 23456789.0|
| East| 34567890.0|
+------+----------+
All these operations reuse the cached partitions — no re-read, no recompute.
Unpersist when done
After your iteration, free the memory:
base_df.unpersist()
Or, if you want to force eviction immediately, you can pass blocking=True.
Complete runnable script in a notebook cell
from pyspark.sql.functions import col
import time
# 1. Load and prepare base_df
sales_df = spark.read.format("delta").load("/mnt/datalake/sales")
base_df = sales_df.filter(col("year") == 2024).select("customer_id", "amount", "region")
# 2. Cache and materialize
base_df.cache()
base_df.count() # triggers cache fill
# 3. Iterate fast
start = time.time()
for region in ["North", "South", "East"]:
avg_amount = base_df.filter(col("region") == region).selectExpr("avg(amount)").collect()[0][0]
print(f"Avg amount in {region}: {avg_amount:.2f}")
print(f"Iteration took {time.time() - start:.2f} seconds")
# 4. Clean up
base_df.unpersist()
Expected output (example, totals vary):
Avg amount in North: 345.67
Avg amount in South: 289.12
Avg amount in East: 401.23
Iteration took 0.23 seconds
Compare options / when to choose what
Caching isn't the only tool to speed up iterative workloads. Here's a comparison to decide wisely:
| Option | Mechanism | Best for | When to avoid | Storage |
|---|---|---|---|---|
df.cache() |
Stores the DataFrame result in executor memory (and disk spill) | Repeated queries on a moderately-sized DataFrame | Data too large for memory; one-shot queries | Cluster RAM + disk |
df.persist(StorageLevel.xxx) |
Same as cache but with configurable storage levels (e.g., MEMORY_ONLY, DISK_ONLY, MEMORY_AND_DISK_SER) |
When you need control over memory vs. disk trade-offs | Most default use cases (cache defaults are fine) | Varies by level |
| Delta cache (file-level) | Caches underlying files on local SSDs; works even when the DataFrame logic changes | Fast re-scans of the same Delta files across many different DataFrames | Small files or when you change the leaf files often | Local NVMe/SSD |
| No caching + optimized scans (predicate pushdown, column pruning) | Skip unneeded data via the query engine | When each query reads a different subset | When you repeatedly need the same large subset | N/A (source only) |
When to choose what:
- Use df.cache() as the default for iterative exploration — simple, effective.
- Use df.persist() with a specific storage level when you need fine-grained control (e.g., DISK_ONLY for datasets too big for memory).
- Use Delta cache when you're building many different DataFrames from the same Delta table and memory pressure is a concern — it's transparent and requires no code changes.
- Avoid caching entirely when you query each subset only once — the overhead of serialization and storage isn't worth it.
Pro tip: Monitor cache usage with the Storage tab in the Spark UI. It shows which RDDs/DataFrames are cached and how much memory they consume. If cache usage is near 100%, consider
unpersist()or a disk-based storage level.
Troubleshooting & edge cases
Even with a solid understanding, things can go sideways. Here are the most common pitfalls and how to fix them.
Pain: Caching doesn't speed up my queries.
- Cause: You called cache() but never triggered an action, or you only read a small subset that wasn't cached. The cache only holds fully materialized partitions; if your query filters heavily before the cached step, it may not benefit.
- Fix: Call an action (e.g., df.count()) right after cache(). Verify via the Spark UI Storage tab that partitions are marked as cached. Also, cache the result of the transformations you reuse — not the raw source read.
Pain: OutOfMemoryError during caching.
- Cause: The DataFrame is too large for the executor memory.
- Fix: Use df.persist(StorageLevel.MEMORY_AND_DISK) (default) so Spark spills to disk instead of failing. Or consider DISK_ONLY. Also, reduce data size by filtering columns/rows before caching. If the dataset is massive, skip caching and use Delta cache instead.
Pain: Cache is not released, and cluster gets slow.
- Cause: You forgot unpersist(), leaving stale data occupying memory across sessions.
- Fix: Use unpersist() in a finally block or after your iteration. You can also call spark.catalog.clearCache() to clear all cached tables in the session — but that's a sledgehammer; prefer targeted unpersists.
Edge case: Caching after a wide transformation can be inefficient. - If you cache a DataFrame after a shuffle-heavy operation (e.g., join or groupBy), the cached partition layout may be skewed, and subsequent queries might still face skew issues. Caching won't fix data skew; consider repartitioning before caching.
Edge case: Caching with Spark 3.x and Photon. - On Databricks Runtime with Photon enabled, some operations may bypass the cache if they use Photon-optimized execution. Check your runtime settings; if you rely on caching, you may want to disable Photon or adjust query plans.
What you learned & what's next
Excellent work! In this lesson, you learned to explain the core idea behind caching DataFrames for iterative workloads — the lazy evaluation model, why repeated actions are slow, and how cache() stores results in memory for reuse. You also completed a practical exercise using cache(), unpersist(), and compared it to alternatives like persist() and Delta cache.
You can now:
- Identify iterative workloads that benefit from caching
- Implement caching with df.cache() and trigger materialization with an action
- Choose between caching, persistence, and Delta cache based on your scenario
- Monitor and release cache to keep your cluster healthy
This foundation directly connects to the next lesson in your Databricks track, where you'll dive into optimizing joins and aggregations — using the same iterative strategies to tune your Spark jobs for maximum performance.
Keep experimenting: try caching a DataFrame, run your analysis, and watch the Spark UI's Storage tab light up. That hands-on habit will pay off in every future pipeline you build.
Practice recap
Try this: load a Delta table, apply a filter, and cache the result. Then run the same count() twice and note the time difference. Monitor the Spark UI Storage tab, and don't forget to unpersist() when you finish. Experiment with persist(StorageLevel.DISK_ONLY) on a larger dataset to see how it behaves.
Common mistakes
- Calling
cache()but never running an action — caching stays lazy and doesn't materialize, so subsequent queries skip it entirely. - Caching a huge DataFrame without filtering columns/rows, blowing up executor memory and causing OOM errors.
- Forgetting
unpersist()after iteration, leaving stale data and consuming cluster memory until session ends. - Applying
cache()on a DataFrame used only once, adding serialization/storage overhead that slows the job down.
Variations
- Use
df.persist(StorageLevel.DISK_ONLY)when your DataFrame exceeds memory but you still need it across multiple queries. - Rely on Delta cache (file-level, SSD-backed) for fast re-scans of the same Delta table across many different DataFrames — no code changes needed.
- Set
spark.sql.autoBroadcastJoinThresholdand use broadcast joins for repeated small-table lookups instead of caching.
Real-world use cases
- Data scientists iterating on features: cache the base DataFrame after cleaning and then test dozens of feature combinations in seconds.
- ETL development: debug a multi-stage pipeline by caching intermediate outputs to speed up repeated trial runs before deployment.
- Ad-hoc interactive analytics in notebooks: cache a large reference table once and let multiple BI-style queries hit the in-memory copy.
Key takeaways
- Caching stores DataFrame results in executor memory, avoiding re-reads and recomputation in iterative workloads.
cache()is lazy — you must trigger an action (likecount()) to materialize it.- The biggest speedups come when a base DataFrame is reused many times; cache only what you need.
- Use
unpersist()to free memory when done, and watch the Spark UI Storage tab to monitor cache health. - Choose between
cache(),persist()with custom storage levels, and Delta cache based on dataset size and reuse patterns. - Avoid caching for one-shot queries — the overhead can make things slower, not faster.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.