Lazy Evaluation: Intermediate
Each DataFrame is a node with parents
Those links form a graph, and its shape starts to matter once your code branches. Two DataFrames can share a parent, and union gives a single node 2 parents. This chain does both:
Each arrow runs from a parent to the DataFrame built on it.
When both.collect() runs, Spark starts at the action and follows the links backward to every source it can reach. Each node on one of those paths joins the job, and a node on none of them stays a description. Call cheap.collect() instead and the groupBy never runs, because no link leads from by_category to cheap.
A shared node is shared only in your code
Your code shows in_stock once, with 2 children. The plan Spark executes is a tree, so it copies the shared part into each branch: the by_category side gets its own read and filter of products, and the cheap side gets another. One action, 2 scans of the same table. On a 6-row table that costs nothing. On a 300GB table it doubles the read, and no line of your code shows it.
The cost of a branch point is paid once per path that leaves it and reaches the action. Build 3 DataFrames on in_stock, combine them into 1 result, and products is read 3 times.
Where the graph is cut into stages
The scheduler takes the part of the graph an action needs and cuts it wherever rows must move between tasks, the stage boundary from the execution lessons. Everything between 2 cuts runs inside the same task, 1 partition per task. The animation plays a 6-row version of the by_category branch, with products read as 2 partitions and the count finished in 2 shuffle partitions.
(products.filter(F.col("in_stock") == 1).groupBy("category").agg(F.count(F.lit(1)).alias("n")).collect())
| Stage | Work inside it | Tasks | How it ends |
|---|---|---|---|
| 1 | read products, keep in_stock = 1, start the count per category | 2, 1 per input partition | a shuffle that sends each category to a single task |
| 2 | finish the count for each category | 2, 1 per shuffle partition | 3 rows returned to the driver |
The same graph in the Spark UI
In the code from this section, how many times does Spark read products when both.collect() runs?
Reading a file without a schema
That single line launches 2 jobs. The first reads the first line of the input, which gives the column count and, with header=True, the names. The second reads every row of every file to settle each column's type, because a single value of 3.5 at row 40 million turns an integer column into a double. On a 200GB directory that is a full scan, paid again when your first action reads the same files.
| Read call | Jobs during the read | What the job reads |
|---|---|---|
| CSV, no schema | 1 | the first line, for the column count and names |
| CSV with inferSchema=True | 2 | the first line, then every row |
| JSON, no schema | 1 | every row, to collect field names and types |
| Parquet | 1, small | 1 file footer, where the schema is stored |
| Any format with .schema(...) | 0 | nothing: the schema comes from you |
File listing still happens during the read. Spark lists the files on the driver, and when the directory tree holds more paths than spark.sql.sources.parallelPartitionDiscovery.threshold (32 by default), it runs the listing as a job of its own.
pivot without its values
A pivot turns the distinct values of 1 column into output columns. Spark cannot name those columns without the values, so this line runs a job:
The job finds the distinct status values, sorts them and collects them to the driver. Past spark.sql.pivotMaxValues (10,000 by default) the line fails. Listing the values yourself skips the job and pins the output schema: a new status that shows up next month adds no surprise column for every downstream reader.
Extra jobs hiding inside one action
Some actions launch more than 1 job before the result arrives. A sort is the common case: orderBy sends rows to tasks by range of the sort key, and to choose the range boundaries Spark first runs a job that samples the key, then runs the work itself. show() is the other: it runs a job over 1 partition, and if that produces fewer than the 20 rows it wants, it runs another over up to 4 times as many partitions, and again, until it has enough. After a filter that keeps 0.01% of the rows, 1 show() can launch several jobs, each scanning more new partitions than the last.
- ▸Every job row names the source line that triggered it. A job tied to a read or a pivot ran before your action did.
- ▸A 1-task job right after a read is the first-line or footer fetch. A job with 1 task per input partition is a full inference scan.
- ▸A short job right before the main work of a sort is the sampling pass, and a run of growing jobs on 1 show() is the scale-up.
Which lines launch a job before any action runs?
What the value list decides
The orders data holds the statuses Completed, Pending and Refunded, and no row is Cancelled. Which columns does by_status have, and where does the Refunded profit go?
Spark blocks the direct case
It fails when the write is called, before any task runs, with an AnalysisException: Cannot overwrite a path that is also being read from. The check compares the target path with every path the plan reads. Nothing is deleted.
Why the guard exists
| Step | What Spark does | Files in /data/users/ |
|---|---|---|
| read.parquet | records the files it found: part-0 and part-1 | 2 |
| withColumn | adds lower(email) to the recipe | 2 |
| write, before any task | deletes everything at the target path | 0 |
| write, tasks start | each task opens part-0 or part-1 to read its rows | 0 |
Calling users.count() before the write would change nothing. An action computes its answer and keeps no rows, the re-execution rule from the beginner lessons, so the write's job goes back to the files regardless.
When the guard cannot see the path
The guard reads the plan, so it catches only a read the plan still names. Convert the DataFrame to an RDD and back, a common move when the fix is a Python function over rows, and the new plan starts from an anonymous RDD with no path in it. Here clean_row is a Python function that lowercases a row's email:
The RDD still reads /data/users/ lazily, but the plan the guard checks names no file, so the write goes ahead. The old files are deleted, the tasks try to open them, and each fails with an error saying a file does not exist and that the underlying files may have been updated. The job fails, and the path it set out to fix holds no data.
With spark.sql.files.ignoreMissingFiles set to true, which some teams enable to ride out files vanishing during a read, the outcome is worse: the tasks skip the deleted files instead of failing, the job reports success, and the table now has 0 rows.
Write somewhere new, then switch
- Write each run to a new path and move readers over once the output is checked
- Treat a job whose input and output paths match as a bug, even when Spark lets it run
- Keep ignoreMissingFiles off in jobs that overwrite, so a missing input fails the run
- Count the input before the write and assume its rows are now held in memory
- Round-trip through .rdd and then overwrite the path you read from
- Overwrite in place to save storage; a single failed run costs you the whole table
clean_row lowercases the email field. What happens when the last line runs?
Narrow and wide
Sort every transformation by what 1 output partition needs. A narrow transformation builds each output partition from exactly 1 input partition: filter, select, withColumn and union work on the rows in front of them and nothing else. A wide transformation needs rows from many input partitions, because rows that share a key can sit in any of them: groupBy, distinct and orderBy are wide. Every shuffle and every stage cut you have seen so far sits at a wide transformation.
A chain of narrow steps runs as 1 pass
The animation plays a 3-step chain on 6 order_items rows split into 2 partitions: keep rows with quantity >= 2, compute line_total, keep 2 columns.
(order_items.filter(F.col("quantity") >= 2).withColumn("line_total", F.col("quantity") * F.col("unit_price")).select("order_id", "line_total").collect())
What pipelining buys, and what it hides
| Step | Narrow or wide | Runs in |
|---|---|---|
| read transactions | source | stage 1 |
| filter quantity > 0 | narrow | stage 1 |
| withColumn unit_price | narrow | stage 1 |
| groupBy + avg | wide | starts in stage 1, finishes in stage 2 |
| filter avg_unit_price > 20 | narrow | stage 2 |
| orderBy | wide | range shuffle ends stage 2, sort runs in stage 3 |
| collect | action | stage 3 sends the rows to the driver |
Run the narrow chain on the practice data. Then add .groupBy("order_id").agg(F.sum("line_total").alias("order_total")) to the end of result and say which steps now run in stage 1 and which in stage 2.
from pyspark.sql import functions as F
result = (order_items
.filter(F.col("quantity") >= 2)
.withColumn("line_total", F.col("quantity") * F.col("unit_price"))
.select("order_id", "product_id", "line_total"))
result.show()With the groupBy added, stage 1 holds the read, the filter, line_total, the select and the first half of the sum: each task adds up line_total per order_id for the rows it holds. The shuffle sends each order_id to a single task, and stage 2 adds those partial sums into order_total. The 3 narrow steps still cost 1 pass over the input, and the groupBy is the only new boundary in the job.
How many stages does this job run, and how does stage 1 treat each row?
To place a step you have not met before, ask what 1 output partition needs. A filter on a column, a withColumn with arithmetic and a select need only the row in hand, so they join the current stage. A distinct must compare a row against copies that may sit in any partition, and an orderBy must know where every row ranks, so each one ends the stage. When a wide step cannot be avoided, putting the narrow filters before it means the shuffle moves fewer rows.
This job has 1 shuffle. Which steps run in stage 1, before it?
Lineage: the recipe a DataFrame carries
The DAG: the schedule for 1 job
- Recorded as each transformation is defined
- Lasts as long as the DataFrame
- Operations and the parents they read
- Answers: how is this partition rebuilt?
- Visible in your code as the chain of variables
- Built when an action submits a job
- Discarded when that job ends
- Stages and the tasks inside them
- Answers: what runs, in what order?
- Visible in the Spark UI, 1 diagram per job
Following both through 4 lines
| Line | Lineage afterwards | DAG afterwards |
|---|---|---|
| clean = ... | read page_views, filter | none |
| per_page = ... | read, filter, groupBy and count | none |
| per_page.collect() | unchanged | job 1: 2 stages, discarded when it ends |
| per_page.filter(...).collect() | a new DataFrame: per_page plus 1 filter | job 2: 2 stages, built from scratch |
What each one does after a lost executor
These 3 lines have run and no action has been called. What does Spark hold for top?
> A subscription company's nightly billing job reads raw CSV invoices with inferSchema, pivots revenue by plan without listing the plans, and splits the cleaned rows into 2 branches, monthly and annual, that it unions back into 1 report. A second step reads the published report, patches rows in a Python function via .rdd, and overwrites the report's own path. The Spark UI shows 3 jobs before the first action, the report job scans the invoices 2 times, and one night the patch step fails and leaves the report path empty.
See the graph Spark builds before it runs, catch the jobs that fire early, and stop a write from eating its own input.
- Category
- Spark
- Difficulty
- intermediate
- Duration
- 14 minutes
- Challenges
- 11 hands-on challenges
Topics covered: The DAG as a Graph, Jobs That Run Before an Action, Overwriting What You Read, Pipelining Narrow Operations, DAG vs Lineage
Lesson Sections
- The DAG as a Graph
Each DataFrame is a node with parents A transformation returns a new DataFrame that stores 2 things: the operation, and a link to the DataFrame it was built from. No rows move. Chain 5 transformations and you hold 5 linked nodes, the last pointing back through the others to the source table. A shared node is shared only in your code Where the graph is cut into stages Four of the 6 rows pass the filter (product ids 1, 2, 4 and 5), and the driver receives Kitchen 2, Garden 1 and Toys 1. No stage-2
- Jobs That Run Before an Action (concepts: Spark UI Diagnosis)
The rule from the beginner lessons holds most of the time: transformations describe, actions run. It bends whenever Spark cannot finish describing the result without looking at data. A DataFrame must know every column's name and type from the line that creates it, and a few operations must know their output columns as well. When that information lives only inside the files, Spark launches a job on the spot, during the line you wrote. Reading a file without a schema CSV and JSON files carry no co
- Overwriting What You Read (concepts: Spark UI Diagnosis)
A DataFrame read from a path holds the list of files found there and the recipe to apply to them. The rows stay on disk until a job opens those files, which is safe as long as the files stay put, and dangerous the day a job writes over the path its own input points at. Spark blocks the direct case This job lowercases every email and writes the result back where it came from: Why the guard exists Overwrite mode empties the target first, then runs the job that produces the new files. With the guar
- Pipelining Narrow Operations (concepts: Spark Execution Model)
Narrow and wide A chain of narrow steps runs as 1 pass Spark runs consecutive narrow transformations as 1 pass, which is called pipelining. The task reads a row, applies the first operation, hands the result to the second, and carries on to the end of the chain before it reads the next row. No step runs over the whole partition before the next begins, and no intermediate DataFrame is stored anywhere. The board shows 1 stage of 2 tasks and no shuffle. Each task carries its rows through all 3 step
- DAG vs Lineage (concepts: Spark Execution Model)
Two names come up for the graph behind a DataFrame, and they refer to different objects with different lifetimes. Confusing them leads to wrong guesses about when work happens and what Spark repeats after a failure. Lineage: the recipe a DataFrame carries Lineage is the chain of parent links from the first section: each DataFrame records its operation and its parents, all the way back to the source. It exists from the line that defines the DataFrame and lasts as long as the DataFrame does. It ho