← Back to Articles

Spark Pill 2: How Does the Spark DAG really work? And Why it doesn't execute line by line

Have you ever wondered why Spark waits until you call .show() before reading a single byte? Let's look at lazy evaluation, the DAG, and the Catalyst optimizer.

SparkLazy EvaluationDAGCatalystQuery Optimization

This is Pill 2 of a series for junior data engineers and data scientists who can run PySpark jobs but don’t understand what happens underneath. Each pill starts from a real question.

Have you ever wondered why nothing happens when you write spark.read.csv("sales.csv")? In Pandas, pd.read_csv() immediately loads the file into memory. In Spark, that line creates a plan but reads zero bytes from disk. Let me show you why this design choice makes Spark significantly faster.

Lazy evaluation: building the map before walking

When you write Spark code, each transformation adds a step to a plan. Nothing executes.

df = spark.read.csv("sales.csv", header=True, inferSchema=True)   # Plan: read file
filtered = df.filter(df.revenue > 1000)                            # Plan: add filter
selected = filtered.select("country", "revenue")                   # Plan: add projection
grouped = selected.groupBy("country").agg(sum("revenue"))          # Plan: add aggregation

At this point, Spark has built a Directed Acyclic Graph (DAG): a complete map of every operation you want to perform, connected in order, with no cycles. It looks like this:

 Read CSV


 Filter (revenue > 1000)


 Select (country, revenue)


 GroupBy (country) + Sum (revenue)

No data has moved. No file has been opened. The DAG is just a data structure in the Driver’s memory.

Only when you call an action does Spark execute:

grouped.show()   # NOW Spark reads, filters, selects, groups, and displays

This is lazy evaluation. Spark collects the entire recipe before cooking.

Why is laziness better than eagerness?

Pandas is eager: each line executes immediately, in the order you wrote it.

# Pandas: each line runs immediately
df = pd.read_csv("sales.csv")                    # Reads ALL columns from disk
filtered = df[df["revenue"] > 1000]               # Scans ALL rows
selected = filtered[["country", "revenue"]]        # Picks 2 columns (after reading all of them)

Look at the waste. Pandas read every column from disk, then threw most of them away. It scanned every row, then discarded the ones below 1000. Two separate passes over data that could have been one.

Spark, because it sees the complete plan before executing, can do something Pandas cannot: optimize the plan.

The Catalyst optimizer

The Catalyst optimizer is the component that sits between your code and execution. When you call an action, Catalyst takes your DAG and rewrites it into a more efficient version.

Here are two key optimizations it performs:

Predicate Pushdown

Your code says: read the file, then filter rows where revenue > 1000.

Catalyst rewrites this to: read the file, but skip rows where revenue <= 1000 during the read itself. The filter moves down to the data source. For columnar formats like Parquet, this can skip entire row groups without reading them, saving enormous amounts of disk I/O.

 YOUR PLAN                    OPTIMIZED PLAN
 ┌──────────┐                 ┌──────────────────────┐
 │ Read CSV │                 │ Read CSV             │
 └────┬─────┘                 │ + Filter: rev > 1000 │
      │                       │ + Project: country,  │
 ┌────▼──────────────┐        │            revenue   │
 │ Filter: rev > 1000│        └──────────┬───────────┘
 └────┬──────────────┘                   │
      │                       ┌──────────▼───────────┐
 ┌────▼──────────────────┐    │ GroupBy + Sum         │
 │ Select: country, rev  │    └──────────────────────┘
 └────┬──────────────────┘

 ┌────▼──────────────────┐
 │ GroupBy + Sum          │
 └───────────────────────┘

Two fewer stages. Less data read from disk. Same result.

Projection Pruning

Your table has 50 columns, but you only use country and revenue. Catalyst tells the data source to read only those 2 columns. With Parquet (a columnar format), this means physically reading 2 out of 50 column chunks. A 96% reduction in disk I/O.

An eager system like Pandas reads all 50 columns first, then discards 48. The wasted I/O already happened.

Fusing operations

Catalyst can also merge consecutive narrow transformations into a single pass. A filter() followed by select() followed by withColumn() becomes one function applied to each row, instead of three separate iterations over the data.

In an eager system, each step creates a new intermediate DataFrame in memory. Three operations means three passes and three temporary copies. Catalyst fuses them into one.

The four plan phases

You can see exactly what Catalyst does by calling .explain(True) on any DataFrame:

grouped.explain(True)

This prints four plans:

1. Parsed Logical Plan

What you wrote, translated directly into Spark’s internal representation. No optimization yet.

2. Analyzed Logical Plan

Spark resolves column names, checks types, and validates that the operations make sense. If you reference a column that does not exist, the error appears here.

3. Optimized Logical Plan

Catalyst applies its rules: predicate pushdown, projection pruning, constant folding, filter reordering, and more. This is where the plan shrinks.

4. Physical Plan

Catalyst chooses concrete execution strategies. For a join, it picks between BroadcastHashJoin, SortMergeJoin, or ShuffledHashJoin based on data sizes. For an aggregation, it decides whether to use a hash-based or sort-based approach. This is the plan that actually runs.

# Try this in your next Spark session
df = spark.read.parquet("large_table.parquet")
result = (df
    .filter(df.year == 2024)
    .filter(df.revenue > 0)
    .select("country", "revenue", "year")
    .groupBy("country")
    .agg(sum("revenue"))
)

# See all four phases
result.explain(True)

Look at the optimized plan. You will see that both filters are pushed down to the Parquet scan, and only 3 columns are read instead of the full schema.

Actions vs Transformations: the complete picture

In Pill 1 we introduced this distinction briefly. Let’s make it precise.

Transformations return a new DataFrame. They are lazy. They add a node to the DAG but trigger no computation.

TransformationWhat it adds to the plan
filter() / where()A selection condition
select()A column projection
withColumn()A derived column expression
groupBy().agg()An aggregation with shuffle boundary
join()A join with shuffle boundary
orderBy()A sort with shuffle boundary
distinct()A deduplication with shuffle boundary

Actions return a result to the Driver or write to an external system. They trigger execution of the entire DAG.

ActionWhat it does
show()Displays rows in the console
count()Returns the number of rows
collect()Returns all rows to the Driver as a Python list
first() / head()Returns the first row(s)
write.parquet()Writes the result to disk
toPandas()Converts to a Pandas DataFrame on the Driver

Every action triggers a complete execution cycle: Catalyst optimizes the plan, the plan is split into stages, tasks are distributed to executors, and data flows through the pipeline.

This means calling .count() twice on the same DataFrame executes the entire pipeline twice. Spark does not cache results automatically. If you need the same intermediate DataFrame multiple times, you should .cache() or .persist() it explicitly.

Stages: where the shuffle draws the line

Catalyst produces a physical plan that Spark then splits into stages. The rule is simple: every shuffle boundary creates a new stage.

Consider this pipeline:

df = spark.read.parquet("events.parquet")        # Read
filtered = df.filter(df.event_type == "purchase") # Narrow
selected = filtered.select("user_id", "amount")   # Narrow
grouped = selected.groupBy("user_id").agg(sum("amount"))  # Wide (shuffle)
result = grouped.filter(col("sum(amount)") > 100)  # Narrow

Spark creates two stages:

 STAGE 1 (before the shuffle)          STAGE 2 (after the shuffle)
 ┌─────────────────────────────┐       ┌─────────────────────────────┐
 │ Read Parquet                │       │ Aggregate partial sums      │
 │ Filter: event_type=purchase │       │ Filter: sum(amount) > 100   │
 │ Select: user_id, amount     │ ────> │                             │
 │ Partial aggregation         │shuffle│                             │
 └─────────────────────────────┘       └─────────────────────────────┘

Stage 1 runs entirely locally on each executor. All the narrow transformations are fused together. At the end of Stage 1, Spark also performs a partial aggregation: each executor sums the amounts per user_id for its local partitions. This reduces the amount of data that needs to cross the network during the shuffle.

Stage 2 receives the partially aggregated data and combines it into final sums, then applies the post-aggregation filter. Because the filter sum(amount) > 100 depends on the aggregation result, it cannot be pushed before the shuffle. Catalyst knows this.

If your pipeline had two wide transformations (say, a groupBy followed by a join), you would get three stages. The number of stages equals the number of shuffles plus one.

A practical example: seeing the plan

Here is a pipeline you can run to see Catalyst in action:

from pyspark.sql import SparkSession
from pyspark.sql.functions import col, sum, year

spark = SparkSession.builder.getOrCreate()

# Imagine a table with 50 columns, including: order_date, country, revenue, status
orders = spark.read.parquet("orders.parquet")

result = (orders
    .filter(year(col("order_date")) == 2024)
    .filter(col("status") == "completed")
    .filter(col("revenue") > 0)
    .select("country", "revenue")
    .groupBy("country")
    .agg(sum("revenue").alias("total_revenue"))
    .orderBy(col("total_revenue").desc())
)

result.explain(True)

In the optimized plan, you will see:

  • All three filters pushed down to the Parquet scan
  • Only country, revenue, order_date, and status read from disk (not all 50 columns)
  • The three filters merged into a single condition
  • Two shuffles: one for the groupBy, one for the orderBy, creating three stages

This is the power of lazy evaluation. Because Spark sees the whole plan, it makes the whole plan better.

Wrapping up

Spark does not execute your code line by line. It collects every transformation into a DAG, hands it to the Catalyst optimizer, and only executes when an action demands a result. This lazy approach enables optimizations that are impossible in an eager system: pushing filters to the data source, reading only necessary columns, and fusing multiple operations into a single pass.

The key concepts from this pill:

  • Lazy evaluation: transformations build a plan, actions execute it
  • Catalyst: rewrites the plan for efficiency (predicate pushdown, projection pruning, operation fusion)
  • Four plan phases: parsed, analyzed, optimized, physical
  • Stages: every shuffle boundary creates a new stage. Narrow transforms within a stage are fused

In the next pill, we will zoom into the shuffle itself. What actually happens when Spark writes shuffle data to disk? Why does it accept the cost of disk I/O in an “in-memory” engine? The answer involves TCP sockets, hash functions, and a fundamental trade-off between speed and fault tolerance.


Test yourself: Pill 2 Quiz: Lazy Evaluation & the DAG

Next up: Pill 3: What Is the Shuffle and Why Does Spark Write to Disk?

Series: Spark Pills. Reinforcement notes for data engineers who can run jobs but want to understand the internals. Born from real production questions.