◐ ai-teacher

🔥 Spark & Databricks · Chapter 3

Transformations vs actions, lazy evaluation

Your filter 'took 0.002 seconds'. It took nothing because it did nothing — yet.

Study this lesson free in the app →

The basics

Lazy evaluation means Spark does not run a transformation when you write it. It records it in a plan and runs the whole plan only when an action asks for a result.

Why it exists: if Spark ran each line immediately, it would read the full rides table, then filter it, then drop columns — materialising huge intermediate results. By waiting until it sees the whole chain, Spark's optimiser (Catalyst) can push the filter into the file scan, read only the three columns you use, and fuse steps into one pass.

Mental model: writing a shopping list versus going to the shop. filter, select, join add lines to the list. count, write, show are the trip. Every trip walks the whole list again, unless you stored the groceries (cache).

Words you must know 5

Transformation
returns a new DataFrame, runs nothing (filter, select, withColumn, join, groupBy().agg)
Action
returns a value or writes data, triggers a job (count, collect, show, take, write)
Lineage
the recorded chain of transformations that produced a DataFrame
Catalyst
the optimiser that rewrites the plan before running it
cache / persist
keep computed partitions so the next action reuses them

Explained

A Meghalaya Cabs engineer wraps each line of a pipeline in a timer. The log says filter: 0.002s, join: 0.001s, write: 41 min.

She spends a day optimising the write. The write was fine. The join was the problem — it just did not run until the write asked for rows.

In Spark, the line that is slow is never the line that is expensive.

The shopping list

You write a list: milk, but only the toned one; eggs; skip anything over ₹200. Writing it costs nothing. Nothing is bought.

When you finally go — that is the action — you read the whole list first, so you can plan a route: dairy aisle once, skip the expensive shelf entirely.

If your partner sends you back for one more item, you walk the whole list again, unless you left the bags in the car. Those bags are cache().

From your code to running tasks

All the cost lands on the action line. That's why timing individual transformation lines tells you nothing.

What laziness buys

When an action fires, Catalyst turns the lineage into a plan in four passes: analysed (resolve columns and types) → optimised logical (rewrite rules) → physical (pick join strategies) → code generation.

The rewrites only possible because nothing ran early:

And lineage is the fault-tolerance model: lose a partition, recompute it from the recipe. No replication of intermediate data needed.

Seeing the plan, and timing honestly

from pyspark.sql.functions import col, sum

by_city = (spark.read.parquet('s3://mc-lake/silver/rides/')
    .filter(col('status') == 'COMPLETED')
    .groupBy('city').agg(sum(col('fare_paise') / 100).alias('revenue')))

by_city.explain('formatted')
# FileScan parquet ... PushedFilters: [IsNotNull(status), EqualTo(status,COMPLETED)]
#                      ReadSchema: struct<status:string,fare_paise:bigint,city:string>

# Wrong: times building the plan
t = time.time(); df2 = by_city.filter(col('revenue') > 1e5); print(time.time() - t)

# Right: time an action that forces the work
t = time.time(); by_city.write.mode('overwrite').parquet('s3://mc-lake/gold/rev/')
print(time.time() - t)

PushedFilters and a short ReadSchema are proof that laziness paid off. If your filter is not listed there, something (a UDF, a cast) blocked the pushdown.

At 100×: the double-scan bill

A pipeline that does df.count() for a log line, then df.write, scans a 5TB table twice. At Databricks prices that log line can cost more than the job.

Cache only what is reused and expensive to recompute — a filtered, joined DataFrame feeding three outputs. Do not cache a raw read you use once; you pay memory and gain nothing.

Prefer persist(StorageLevel.MEMORY_AND_DISK) for large reused sets so evicted partitions spill instead of being recomputed from S3. Call unpersist() when done; cached blocks otherwise compete with execution memory.

Very long lineages (iterative loops of hundreds of joins) make planning slow and recovery expensive. checkpoint() or writing an intermediate table cuts the lineage.

Gotchas

Transformations write the list. Actions go shopping. Every trip walks the whole list.

Time actions, not lines. Cache only what two actions share.

Words to know

TransformationAn operation that returns a new DataFrame and adds a step to the plan without running anything.
ActionAn operation that returns a result to the driver or writes data, triggering a job.
LineageThe recorded chain of transformations from source to DataFrame; used to recompute lost partitions.
CatalystSpark SQL's optimiser: analyses, rewrites, and physically plans the query before execution.
Predicate pushdownMoving a filter into the data source so non-matching files or row groups are never read.
Column pruningReading only the columns the query uses — a large saving with columnar formats like Parquet.
persist / cacheKeep a DataFrame's computed partitions (memory, or memory and disk) so later actions reuse them.

In two minutes

Test yourself

1. Which of these triggers a Spark job?

  1. df.withColumn('x', lit(1))
  2. df.join(drivers, 'driver_id')
  3. df.write.parquet(path)
  4. df.cache()
Show the answer

df.write.parquet(path) — write is an action. withColumn and join are transformations, and cache() is lazy — it only marks the DataFrame.

2. What does laziness let Catalyst do that eager execution could not?

  1. Push filters into the file scan and read only used columns
  2. Run Python faster
  3. Skip the shuffle in every groupBy
  4. Avoid needing executors
Show the answer

Push filters into the file scan and read only used columns — Seeing the whole query before running it allows pushdown and pruning. groupBy still needs a shuffle (unless data is already partitioned that way).

3. A DataFrame is not cached. You call count() then write(). How many times is the source read?

  1. Once
  2. Zero — count uses metadata
  3. It depends on the cluster size
  4. Twice
Show the answer

Twice — Each action is an independent job running the full lineage. Without cache, the source is scanned for count and again for write.

4. A pipeline computes clean_rides, then writes three gold tables from it. The job reads 2TB from S3 three times. Fix?

  1. Raise spark.sql.shuffle.partitions
  2. Persist clean_rides (MEMORY_AND_DISK) before the three writes and unpersist after
  3. Call clean_rides.collect() first
  4. Use more executors
Show the answer

Persist clean_rides (MEMORY_AND_DISK) before the three writes and unpersist after — The shared intermediate is recomputed per action. Persisting it makes the first write fill the cache and the other two reuse it. collect() would kill the driver.

5. A UDF throws ZeroDivisionError on some rides. In the stack trace, which line of your code does the failure surface at?

  1. The withColumn that applies the UDF
  2. The spark.read line
  3. The action that forced execution, such as write()
  4. It never fails; Spark returns null
Show the answer

The action that forced execution, such as write() — The UDF only runs when an action executes the plan, so the job fails there. The withColumn line merely recorded it.

6. You add print(df.count()) after every step of a 6-step pipeline for debugging. What does it cost?

  1. Up to six extra jobs, each re-running the lineage up to that step
  2. Nothing — count is free
  3. One extra job total
  4. It only costs driver memory
Show the answer

Up to six extra jobs, each re-running the lineage up to that step — Each count is an action, and without caching each one recomputes from the source. Remove them before production.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →