Skip to main content
PySpark advanced Lesson 8 of 10

The Spark Execution Model: Jobs, Stages, and Caching

Trace one query from DataFrame to tasks, see where stage boundaries come from, and prove that caching changes the plan rather than just the timing.

Spark’s API hides the execution model well enough that you can write jobs without knowing it, right up until performance matters. This lesson opens it up.

Four layers

Your DataFrame code goes through four representations before running:

  1. Unresolved logical plan — parsed, column names not yet verified
  2. Analysed logical plan — names and types resolved against the catalog
  3. Optimised logical plan — Catalyst has applied its rewrite rules
  4. Physical plan — concrete operators, join strategies, exchanges

explain(True) shows all four:

from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("execution").master("local[*]").getOrCreate()

df = spark.range(1_000_000).select(
    F.col("id"),
    (F.col("id") % 100).alias("bucket"),
    (F.rand() * 1000).alias("amount"),
)

q = df.filter(F.col("amount") > 500).filter(F.col("bucket") < 10).select("id", "amount")
q.explain(True)
== Parsed Logical Plan ==
'Project [unresolvedalias('id), unresolvedalias('amount)]
+- Filter (bucket#2L < cast(10 as bigint))
   +- Filter (amount#3 > cast(500 as double))
      +- Project [id#0L, (id#0L % 100) AS bucket#2L, (rand(...) * 1000.0) AS amount#3]
         +- Range (0, 1000000, step=1, splits=8)

== Analyzed Logical Plan ==
id: bigint, amount: double
Project [id#0L, amount#3]
+- Filter (bucket#2L < 10)
   ...

== Optimized Logical Plan ==
Project [id#0L, amount#3]
+- Filter (((id#0L % 100) < 10) AND ((rand(...) * 1000.0) > 500.0))
   +- Range (0, 1000000, step=1, splits=8)

== Physical Plan ==
*(1) Project [id#0L, amount#3]
+- *(1) Filter (((id#0L % 100) < 10) AND ((rand(...) * 1000.0) > 500.0))
   +- *(1) Range (0, 1000000, step=1, splits=8)

The optimised plan is where the work shows. Two Filter nodes became one. The Project that created bucket disappeared entirely — Catalyst inlined id % 100 into the filter, so the intermediate column is never materialised. You wrote four operations; Spark will run two.

Narrow and wide

The *(1) prefix marks whole-stage code generation — Spark compiled those operators into a single Java method. Everything with the same number runs in one stage, in one pass over the data, with no intermediate objects.

Add a groupBy and the picture changes:

df.groupBy("bucket").agg(F.sum("amount")).explain()
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[bucket#2L], functions=[sum(amount#3)])
   +- Exchange hashpartitioning(bucket#2L, 200), ENSURE_REQUIREMENTS
      +- HashAggregate(keys=[bucket#2L], functions=[partial_sum(amount#3)])
         +- Project [(id#0L % 100) AS bucket#2L, (rand(...) * 1000.0) AS amount#3]
            +- Range (0, 1000000, step=1, splits=8)

Exchange is a stage boundary. Everything below it is stage 0; everything above is stage

  1. Stage 0 writes its output to local disk as shuffle files, stage 1 fetches them over the network.

That is the whole rule:

KindOperationsData movementStages
Narrowfilter, select, withColumn, mapnonesame stage
WidegroupBy, join, distinct, repartition, orderByshufflenew stage

Counting Exchange nodes in a plan tells you how many times your data crosses the network, which is usually the number that decides runtime.

Jobs, stages, tasks

result = df.groupBy("bucket").agg(F.sum("amount")).collect()

One action triggers one job. The job has two stages because of the one shuffle. Each stage runs one task per partition — 8 tasks in stage 0 (the Range had 8 splits), then 200 in stage 1 before AQE coalesces them.

Check the partition counts directly:

print("input partitions: ", df.rdd.getNumPartitions())
print("after groupBy:    ", df.groupBy("bucket").agg(F.sum("amount")).rdd.getNumPartitions())
print("after coalesce:   ", df.coalesce(2).rdd.getNumPartitions())
print("after repartition:", df.repartition(16).rdd.getNumPartitions())
input partitions:  8
after groupBy:     200
after coalesce:    2
after repartition: 16

coalesce merges partitions without a shuffle and can only reduce. repartition shuffles and can do either, and gives you even sizes. Use coalesce before a write to cut file count; use repartition when partitions are unevenly sized.

Lineage and recomputation

Spark stores a recipe, not results. Two actions mean two full computations:

import time

expensive = df.filter(F.col("amount") > 100).groupBy("bucket").agg(F.avg("amount").alias("avg"))

start = time.perf_counter(); expensive.count();          t1 = time.perf_counter() - start
start = time.perf_counter(); expensive.collect();        t2 = time.perf_counter() - start
print(f"first action:  {t1:.2f}s")
print(f"second action: {t2:.2f}s")
first action:  1.84s
second action: 1.79s

No speed-up. The second action re-ran the scan, the filter, and the shuffle from scratch.

Cache it:

expensive.cache()

start = time.perf_counter(); expensive.count();   t1 = time.perf_counter() - start
start = time.perf_counter(); expensive.collect(); t2 = time.perf_counter() - start
print(f"first action (populates cache): {t1:.2f}s")
print(f"second action (from cache):     {t2:.2f}s")
first action (populates cache): 1.97s
second action (from cache):     0.04s

Forty times faster. The first action paid a little extra to write the cache; every action after reads it.

Caching changes the plan, not just the timing:

expensive.explain()
== Physical Plan ==
InMemoryTableScan [bucket#2L, avg#31]
   +- InMemoryRelation [bucket#2L, avg#31], StorageLevel(disk, memory, deserialized, 1 replicas)
         +- *(3) HashAggregate(keys=[bucket#2L], functions=[avg(amount#3)])
            +- ...

InMemoryTableScan replaced the whole subtree. The computation below is only run once, to populate the relation.

Cache is lazy too

other = df.filter(F.col("bucket") == 5)
other.cache()
print(spark.catalog.isCached("..."), "— nothing computed yet")

cache() marks a DataFrame for caching but computes nothing. Until an action runs, the cache is empty. This trips people up when they cache and immediately check the Storage tab and find it empty. Force it:

other.cache().count()

Free it when done — cached data holds executor memory that other work needs:

expensive.unpersist()

When caching hurts

cheap = df.select("id", "amount")

start = time.perf_counter(); cheap.count(); cheap.count()
print(f"uncached, two counts: {time.perf_counter() - start:.2f}s")

cheap.cache()
start = time.perf_counter(); cheap.count(); cheap.count()
print(f"cached,   two counts: {time.perf_counter() - start:.2f}s")
cheap.unpersist()
uncached, two counts: 0.34s
cached,   two counts: 1.12s

Slower. Serialising a million rows into the cache cost more than recomputing a trivial projection twice. Cache when the work below is expensive — after a shuffle, a wide join, or a UDF — not reflexively.

Non-determinism without caching

r = df.select("id", F.rand().alias("r")).filter(F.col("r") > 0.5)
print("count 1:", r.count())
print("count 2:", r.count())
count 1: 500204
count 2: 499876

Different answers from the same DataFrame. rand() is re-evaluated on each action because the lineage is recomputed. Anything downstream of a non-deterministic expression must be cached — or given a seed — before you use it twice.

r = df.select("id", F.rand(seed=42).alias("r")).filter(F.col("r") > 0.5).cache()
print("count 1:", r.count())
print("count 2:", r.count())
count 1: 500118
count 2: 500118

Practice

1. Count the Exchange nodes in a plan with two joins and a groupBy.

Typically three to five, depending on which joins Spark broadcasts. Each one is a full pass of data over the network. Reducing exchange count — by broadcasting, by pre-partitioning, by reordering joins so the most selective runs first — is the highest-leverage Spark optimisation there is.

2. Compare coalesce(1) and repartition(1) on a filtered DataFrame.
coalesce(1):    2.31s
repartition(1): 0.88s

Counter-intuitive but consistent: coalesce(1) avoids a shuffle by collapsing the work into one task, so the upstream filter also runs single-threaded. repartition(1) shuffles, but the filter still runs in parallel first. When the upstream work is heavy, repartition wins despite the shuffle.

3. Cache a DataFrame, run an action, then check the storage level.
print(expensive.storageLevel)
Disk Memory Deserialized 1x Replicated

cache() on a DataFrame means MEMORY_AND_DISK, so partitions that do not fit in memory spill rather than being recomputed. That differs from RDD cache(), which is memory-only and silently drops partitions under pressure.

4. Add monotonically_increasing_id(), cache, and check the IDs are stable across two actions.

Without caching the IDs regenerate and can differ if partitioning changes. Even cached, the values are not consecutive — they encode the partition number in the upper bits:

0, 1, 2, 8589934592, 8589934593, 17179869184

Use it for uniqueness only, never for row numbering. For consecutive numbers use row_number() over a window, and accept the single-partition cost that comes with it.

Next: turning all of this into a tuning checklist.

Frequently Asked Questions

What creates a new stage?
A shuffle. Spark chains every operation it can do without moving data into one stage and runs them together on each partition. When an operation needs data from other partitions — a groupBy, join, or repartition — it has to write output and start a new stage to read it back.
What is the difference between cache and persist?
cache() is persist(MEMORY_AND_DISK) for DataFrames. persist lets you pick the storage level — memory only, disk only, serialised, or replicated. For DataFrames the default is usually right, since Spark spills to disk rather than recomputing.
Why did my count change between two runs?
Almost always a non-deterministic source in the lineage — rand(), current_timestamp(), monotonically_increasing_id(), or an unordered read. Without caching, Spark recomputes the whole lineage for each action, so those values are regenerated each time.
Should I always cache a DataFrame I use twice?
No. Caching costs memory and adds a materialisation step. It pays off when recomputation is expensive relative to the cache write — after a shuffle, a wide join, or a UDF. For a cheap scan-and-filter, recomputing can be faster than caching.