DE Learning

Spark Core · Module 3 · Deep dive

How plans become stages and tasks

The full submit sequence, what your Python and the cluster are each doing over time, the calls that break the "lazy" rule, and how the scheduler cuts a plan into stages. Start with the overview for the basics.

spark-submit to job completion, in ten steps

  1. Submit. spark-submit (or a Databricks job or notebook) starts the driver with your code and configuration.
  2. Driver starts. It creates the SparkSession and asks the cluster manager for executors.
  3. Executors launch and register with the driver.
  4. Your code runs on the driver. Each transformation adds a step to a logical plan. No data moves.
  5. An action is called. Spark creates a job for that DataFrame's lineage.
  6. Catalyst optimises the logical plan and picks a physical plan (Module 4).
  7. The DAG scheduler cuts the physical plan into stages at every shuffle and submits them in dependency order.
  8. The task scheduler sends one task per partition to free executor cores, preferring executors that already hold the data (locality).
  9. Executors run tasks. A stage that ends in a shuffle writes shuffle files to local disk; the next stage fetches them over the network.
  10. The job finishes when its last stage completes, and the result returns to Python. Failed tasks are retried (4 attempts by default) using lineage. The application ends when the SparkSession stops.

What Python and the cluster are doing over time

The same code as the overview, drawn as two lanes. Python is busy for microseconds per transformation, then blocks at each action while the cluster runs a job.

Python lane runs transformations instantly and blocks at count and write; cluster lane runs Job 1 and Job 2 during those blocks Python(driver) Cluster(executors) read filter count() · waiting Job 1 scan → filter → count if n > 0 groupBy write · waiting Job 2 scan again → filter → aggregate → write time → transformations take microseconds; each action blocks Python until its job finishes
LinePythonSpark
readRunsLists files and reads the schema (may be a tiny job)
filterRuns instantlyThe logical plan grows by one node
count()BlocksOptimises read → filter, runs Job 1, returns a number
if n > 0Plain PythonNot involved
groupByRuns instantlyA new plan built on top of ae's lineage
writeBlocksRuns Job 2, which re-reads and re-filters orders

Not quite lazy: calls that run work immediately

CallWhat runs eagerly
spark.read… with inferSchema=true (CSV, JSON)A job that scans data to work out column types. Give a schema to avoid it.
spark.read.parquet(…) on many filesFile listing and footer reads to build the schema
DataFrame transformations in classic PySparkAnalysis: a misspelt column raises AnalysisException on that line, before any action
df.checkpoint()Runs a job to materialise the data (eager by default)
RDD sortByKeyA sampling job to decide range-partition boundaries
spark.sql("CREATE TABLE …"), INSERT, DDLCommands execute when called
df.cache()Nothing: caching is lazy, and data is cached during the next action
df.explain()Planning only; no job runs
Spark Connect moves errors to the action
With Spark Connect (Spark 3.4+, Databricks serverless and shared compute), the client sends unresolved plans and the server analyses them. A misspelt column may only fail at the action, not on the line where you made the mistake.

Why lazy, and why immutable

  • Whole-plan optimisation. Seeing the full chain lets Catalyst push filters into the file scan and read only the needed columns.
  • Fewer passes. Consecutive narrow steps are fused into one stage, so each row is read once and flows through all of them.
  • Fault tolerance through lineage. The plan is a recipe. If an executor dies, Spark recomputes only the lost partitions from that recipe; no data replication needed.

An RDD, and the DataFrame built on it, is an immutable, partitioned dataset plus the lineage that produced it. Every transformation returns a new dataset. Immutability is what makes recomputation safe (a rebuilt partition equals the original) and lets tasks run in parallel without locks.

Partitions, narrow and wide

Narrow transformation maps partitions one to one; wide transformation shuffles all to all across a stage boundary Narrow · filter, select, withColumn, map same stage, no network, can be pipelined P0 P1 P2 P0' P1' P2' Wide · groupBy, join, distinct, orderBy shuffle: every input can send rows to every output P0 P1 P2 key A–H key I–P key Q–Z shuffle = stage boundary

map vs flatMap

rdd = sc.parallelize(["spark is fast", "delta lake"])
rdd.map(lambda s: s.split(" ")).collect()
# [['spark','is','fast'], ['delta','lake']]   one output per input
rdd.flatMap(lambda s: s.split(" ")).collect()
# ['spark','is','fast','delta','lake']         zero or more outputs, flattened

The DataFrame equivalent of flatMap is F.explode(F.split("text", " ")). Both are narrow.

How the scheduler cuts stages

  1. The DAG scheduler starts from the operation that produces the action's result and walks backwards through the plan's dependencies.
  2. Narrow dependencies are kept in the current stage. Each shuffle dependency ends the walk for that branch and creates a parent stage that must write its shuffle output first.
  3. The final stage is a ResultStage (it computes the action's result). Every stage that feeds a shuffle is a ShuffleMapStage.
  4. Stages are submitted parent-first. Parents that don't depend on each other, such as the two scans before a join, can run at the same time.

How many tasks

Stage typeTasks
Reading filesAbout one per spark.sql.files.maxPartitionBytes (128 MB) of input; small files are packed together
After a shufflespark.sql.shuffle.partitions (200), or fewer if AQE coalesces
After repartition(n) / coalesce(n)n
RDD from parallelizespark.default.parallelism (usually total cores)

Skipped stages

In the Spark UI, a stage marked skipped was needed by this job, but its shuffle output already existed from an earlier job, so Spark reused it. That's normal and good: it's Spark avoiding recomputation, for example when two actions share the same aggregation.

With AQE, one action shows several jobs
Adaptive Query Execution runs a query in pieces called query stages, re-planning between them. In the Jobs tab, one write can appear as several jobs. Read them together in the SQL / DataFrame tab, which shows the whole query.

reduceByKey vs groupByKey

Both group by key and both shuffle. The difference is how much they shuffle.

groupByKey

Ships every (key, value) pair across the network, then builds the full list of values per key in memory on the reducer side.

map side: (a,1) (a,1) (a,1) (b,1)
shuffled: 4 records

A hot key's whole value list must fit in one task's memory: OOM risk.

reduceByKey

Combines values on the map side first (like a Hadoop Combiner), then shuffles one partial result per key per partition.

map side: (a,3) (b,1)
shuffled: 2 records

Less network, less memory. Use it, or aggregateByKey, whenever the function is associative.

DataFrames do this automatically: groupBy().agg(sum(…)) plans a partial_sum before the shuffle and a final sum after. You can see both in explain(). Background: the MapReduce Combiner.

Compared with Lakeflow Declarative Pipelines

Plain PySpark is lazy per action. Lakeflow Declarative Pipelines (formerly Delta Live Tables) work the opposite way: your Python is evaluated once, up front, only to build a graph. Each table function returns a DataFrame definition but runs nothing. The pipeline engine then sees every table and dependency together, orders them, and runs the whole graph with retries, incremental processing and data-quality checks. That's why calling count() or collect() inside a pipeline table function is an anti-pattern.

A full comparison page will come with the Delta Lake & Databricks path.

Check yourself

What's the difference between a ShuffleMapStage and a ResultStage?
A ShuffleMapStage produces shuffle output for a later stage. A ResultStage is the final stage of a job and computes the action's result (rows for collect, a number for count, files for write).
The Spark UI shows a job with 3 stages, 1 of them "skipped". Is something wrong?
No. The skipped stage's shuffle output already existed from an earlier job, so Spark reused it instead of recomputing.
A misspelt column fails only when you call write, not on the line with the typo. Why?
You're likely on Spark Connect (or Databricks serverless/shared compute), where plans are analysed on the server at execution time. In classic PySpark the error would appear on the transformation line.
Why can two stages run at the same time?
If neither depends on the other, for example the scans of the two sides of a join, the scheduler submits both once their own parents are complete.