Execution Model: Intermediate
3 nested units
Before any data moves, Spark cuts your program into 3 nested units. A job is the work 1 action asks for. A stage is a stretch of that job in which no row has to leave the task holding it. A task is 1 stage applied to 1 partition, and it is the only unit an executor ever runs.
| Unit | Created by | How many | Where the UI shows it |
|---|---|---|---|
| Job | An action: `collect()`, `count()`, a write | At least 1 per action | Jobs tab, 1 row each |
| Stage | A point where rows must regroup across partitions | Boundaries in the job, plus 1 | Stages tab, 1 row each |
| Task | A stage applied to 1 partition | The stage's partition count | The task table inside a stage |
The first row says "at least" because a few operations, orderBy among them, submit an extra job ahead of yours. To pick its value ranges, the sort samples the rows arriving at it, so that sampling job runs every stage upstream of the sort to produce those rows. Spark keeps the shuffle files that job writes, and your job then lists the stages already done as skipped instead of running them again. The stage counts in this section describe the plan of the job your action submits, and the Lazy Evaluation lessons trace the extra job in full.
Counting the stages in one program
(orders.filter(F.col("status") == "completed").withColumn("net", F.col("amount") - F.col("discount")).groupBy("region").agg(F.sum("net").alias("revenue")).orderBy(F.col("revenue").desc()).collect())
| Stage | What each task does | Tasks | Why the stage ends |
|---|---|---|---|
| 0 | Reads its partition, drops rows that are not completed, computes `net`, keeps a running sum per region | 2, 1 per input partition | `groupBy` needs all rows of a region in 1 task, and a region's rows can sit in any partition |
| 1 | Adds up the partial sums it receives into final revenue per region | 2, 1 per shuffle partition | `orderBy` needs rows split into revenue ranges, so each task can sort 1 range |
| 2 | Sorts its range and sends its rows to the driver | 2, 1 per range | The job is done once `collect()` holds every row |
That plan has 3 stages and 6 tasks for 1 action, and the driver ends up with 210 for US and 145 for EU, with APAC at 75. The Jobs tab shows 2 jobs, though. The sampling job runs stage 0, then 2 tasks that aggregate the regions and sample the result. The main job finds stage 0's shuffle files already written, lists that stage as skipped, and runs stages 1 and 2, so the per-region sums are computed twice and 8 tasks run in all. filter and withColumn never get a stage of their own. Each output row depends on 1 input row, so Spark runs both inside the task that reads the partition and builds the partial sums in the same pass over its rows. A stage ends only where a task would need rows another task holds.
Scale the input to 50GB and the shape stays the same while the numbers grow. Spark reads files in pieces of up to 128MB by default, so the read stage gets around 400 tasks, and it runs once, inside the sampling job. Every stage after a boundary, the sampling pass included, gets 200 tasks, the default value of spark.sql.shuffle.partitions.
These counts assume adaptive execution is off. Spark 3.2 and later turn it on by default and can merge small partitions mid-job, a behaviour with lessons of its own.
In the program below, which call ends the first stage, and which call submits the job?
Why a count() shows 2 stages
Run this on the practice data. It has no groupBy anywhere, only a filter and a new column before the count, yet the Stages tab lists 2 stages for it. Spark counts in 2 halves: each task counts the rows in its own partition, then those per-partition numbers move to a single partition, where 1 task adds them up. That move is a boundary like any other, so the second stage has exactly 1 task.
items = spark.table("order_items")
heavy = (items
.filter(F.col("quantity") >= 2)
.withColumn("line_total", F.col("quantity") * F.col("unit_price")))
heavy.count()Predict before you edit: change the last line to heavy.groupBy("order_id").count().count() and run it. The job grows to 3 stages: the read, the per-order totals, and the single task that counts orders. The number returned changes from line items to orders holding at least 1 such line.
A job reads events as 120 partitions and runs the code below, with spark.sql.shuffle.partitions at 200 and adaptive execution off. How many stages and tasks does it run?
A task sees only its own partition
The barrier between stages
- Each task carries its rows through every step back to back, in memory
- Tasks never wait on each other; each finishes on its own schedule
- A filter or a new column costs CPU time and nothing else
- Every task writes its output to local disk, split by destination partition
- The next stage's tasks fetch their pieces from every executor over the network
- The next stage launches only after the slowest task here has written its output
What creates a boundary
- groupBy(...).agg(...): rows sharing a key must meet in 1 task.
- distinct() and dropDuplicates(): 2 copies of a row can start in different partitions.
- join of 2 large tables: matching keys must land in the same task. A join to a small table can avoid it, as the Joins lessons show.
- orderBy: rows move into value ranges, so each task sorts 1 range and the ranges line up.
- repartition(n) or repartition("col"): moving rows is the whole purpose of the call.
- A window with partitionBy: each window's rows must sit in 1 task.
Everything else you have used so far, filter, select, withColumn, union, runs inside the stage already in progress.
groupBy("region") right after repartition("region") adds no second boundary, since rows already sit grouped by region. And orderBy then limit(10), returned to the driver, runs as a top-N: each task keeps its own top 10, the driver merges them, and no range boundary appears.With adaptive execution off, how many stages does this job run?
A job with 2 boundaries
This program on the practice data counts line items per order and sorts the orders by that count. Trace it before running: a groupBy boundary, then an orderBy boundary, so 3 stages. The rows come back in descending order across the whole result because each task in the last stage sorted 1 contiguous range of counts, and the driver lines the ranges up.
items = spark.table("order_items")
per_order = (items
.filter(F.col("order_id").isNotNull())
.groupBy("order_id")
.agg(F.count("*").alias("lines"))
.orderBy(F.col("lines").desc(), F.col("order_id")))
per_order.collect()Now add .limit(5) to the end of the chain and run it again. The 5 rows match the top of the full result, yet that version runs 2 stages, for the top-N reason in the tip above.
Stage 3 of a job has 200 tasks. 199 of them finish within 2 minutes, and the last finishes at 11 minutes. Stage 4 reads stage 3's output. When does stage 4's first task start?
Where a stage's task count comes from
Every figure in the Tasks column of the Stages tab has a source you can name. A stage that reads files gets its partitions from the input: Spark packs file bytes into partitions no larger than spark.sql.files.maxPartitionBytes, which defaults to 128MB. Several small files can end up in 1 partition. A stage that starts after a boundary gets whatever count the boundary split the data into.
| Stage begins with | Task count comes from | Default |
|---|---|---|
| Reading files | Input bytes divided into pieces of `spark.sql.files.maxPartitionBytes` | 128MB pieces |
| A `groupBy`, `distinct` or join boundary | `spark.sql.shuffle.partitions` | 200 |
| An `orderBy` boundary | `spark.sql.shuffle.partitions`, as value ranges | 200 |
| `repartition(n)` | The `n` you passed | No default |
spark.conf.set("spark.sql.shuffle.partitions", 4)(page_hits.groupBy("device").agg(F.count("*").alias("hits")).collect())
The first stage runs 3 tasks because the input has 3 partitions. The second runs 4 because the setting says 4, even though only 3 devices exist, so at least 1 of its tasks receives no rows: it gets scheduled and finishes without finding anything. The empty task leaves the result alone. Mobile comes back with 4 hits and desktop with 3, and tv has the remaining 2. Spark launches a task for every partition whether it holds data, so a groupBy over 12 distinct keys at the default of 200 leaves at least 188 tasks with no rows to process.
Tasks against slots
| Stage | Tasks | Slots | Waves | Busy slots in the last wave |
|---|---|---|---|---|
| Read 50GB | 400 | 40 | 10 | 40 of 40 |
| After a `groupBy` | 200 | 40 | 5 | 40 of 40 |
| After `repartition(50)` | 50 | 40 | 2 | 10 of 40 |
| After `repartition(8)` | 8 | 40 | 1 | 8 of 40 |
A cluster has 8 executors with 5 cores each. A stage has 100 tasks that each take roughly 30 seconds. Roughly how long does the stage take?
Reading it off the Stages tab
- Total tasks under the slot count. The stage leaves cores idle by construction. It needs more partitions, and more machines would sit idle too.
- Exactly 200 tasks after a boundary. Nobody set spark.sql.shuffle.partitions, and a count that suits 2GB of regrouped data fits 2TB badly.
- Thousands of tasks lasting tens of milliseconds. Launching and tracking each task costs the driver time, and here that cost rivals the work itself.
If a stage after a boundary shows fewer than 200 tasks, the runtime merging noted earlier is the likely reason: in Spark 3.2 and later, small partitions are combined after Spark measures them.
Client mode and cluster mode
In client mode, the default, the driver runs inside the spark-submit process on the machine you launched from: your laptop, a shared gateway box, a notebook server. In cluster mode, spark-submit asks the cluster manager to start the driver on one of the cluster's own nodes, and the driver runs there from then on while spark-submit only reports on it.
Client mode: the driver sits outside the cluster, so task launches and status updates cross the link between your machine and the executors, and so does every collect() result.
Cluster mode: spark-submit hands the application to the cluster manager, which starts the driver on a cluster node next to its executors.
| Question | Client mode | Cluster mode |
|---|---|---|
| Where the driver runs | The machine that ran spark-submit | A node the cluster manager picks |
| Where `collect()` rows land | Your machine's memory | That node's memory |
| Where `print()` output shows | Your terminal or notebook | The driver's log on that node |
| If the launch machine shuts down | The driver dies and the job is lost | The job keeps running |
| Typical use | Shells and notebooks, which need a driver where you type | Scheduled and long-running production jobs |
You run a 40-minute job from a notebook on your laptop, so the driver runs in client mode. At minute 25 the laptop's battery dies. What happens to the job?
The link the driver talks over
Client mode's cost is distance. The driver sends a message to launch every task and receives 1 back when it finishes, so a 400-task stage puts at least 800 messages on the link between your machine and the cluster. A collect() that returns 2GB crosses the same link: over a 50 megabit-per-second VPN that is more than 5 minutes of transfer, where a driver inside the cluster's network would take seconds.
A third arrangement runs on a single machine: --master local[4] puts the driver and 4 task threads in 1 process, so your slots are 4 threads, and shuffle pieces are written and read on the same disk with no network between them.
Checking from inside the program
A running application can tell you which arrangement it got. The property spark.submit.deployMode holds client or cluster, and spark.master holds the master URL.
- Run scheduled production jobs in cluster mode, so a launcher reboot can't end them
- Use client mode for shells and notebooks, where results must reach your screen
- Launch client-mode work from a machine on the cluster's network, close to the executors
- Look for a cluster-mode job's output in the driver log on its node
- Don't collect() a large result to a laptop driver; write it to storage instead
- Don't run nightly jobs in client mode from a laptop
- Don't expect a cluster-mode driver's print() to reach your terminal
A nightly job ends with print(report.collect()) and is launched with --deploy-mode cluster from a scheduler machine. Where do the rows end up?
Every flag is a property
Each named flag of spark-submit is a shortcut for a Spark property, one of the spark.* keys that the Environment tab of the Spark UI lists for a running application. A property with no shortcut goes through --conf key=value, which you can repeat.
| Flag | Property it sets | Default |
|---|---|---|
| `--master` | `spark.master` | `local[*]` |
| `--deploy-mode` | `spark.submit.deployMode` | `client` |
| `--driver-memory` | `spark.driver.memory` | `1g` |
| `--executor-memory` | `spark.executor.memory` | `1g` |
| `--executor-cores` | `spark.executor.cores` | Depends on the cluster manager |
| `--num-executors` | `spark.executor.instances` | Depends on the cluster manager |
| `--conf k=v` | Any property `k` | Whatever `k` defaults to |
Which value wins
One property can be set in 3 places, and Spark settles them in a fixed order. A value set in code on the session builder beats a spark-submit flag or --conf, and a flag beats the cluster's spark-defaults.conf file. Here is 1 job's settings traced through all 3.
| Property | spark-defaults.conf | spark-submit | Session builder | Job runs with |
|---|---|---|---|---|
| `spark.executor.memory` | 4g | 8g | not set | 8g |
| `spark.executor.cores` | 4 | not set | not set | 4 |
| `spark.executor.instances` | 5 | 10 | not set | 10 |
| `spark.sql.shuffle.partitions` | 200 | 400 | 800 | 800 |
Read once at startup, or per query
Precedence decides which value Spark reads; the other question is when Spark reads it. Spark reads executor memory and cores, along with the executor count, when executors launch, and every executor in an application launches with the values the application started with. Most properties under spark.sql., including spark.sql.shuffle.partitions, are read each time a query is planned, so a new value applies from the next action on.
The session builder fails more quietly. In a notebook where a session already exists, SparkSession.builder.config("spark.executor.memory", "8g").getOrCreate() hands back that existing session, and its executors keep the memory they started with.
Driver memory follows the same rule, with a twist from the previous section. In client mode the driver is the very process running your code, so Spark's documentation says not to set spark.driver.memory in code there: by then the driver's JVM has already started. Pass --driver-memory or put it in spark-defaults.conf. In client mode that heap also comes from the machine you launched on, so a laptop with 16GB of RAM can't host a 24g driver.
- spark.executor.memory
- spark.executor.cores
- spark.executor.instances
- spark.driver.memory
- spark.submit.deployMode
- spark.sql.shuffle.partitions
- spark.sql.files.maxPartitionBytes
- Most other spark.sql. properties
Checking what applied
When a job behaves as if a setting never landed, open the Environment tab: it lists every property the application started with and the value that won. A value changed later with spark.conf.set lives in the session and doesn't appear there, so check that one through its effect, such as the task count of the first stage after the next boundary.
spark-defaults.conf sets spark.sql.shuffle.partitions to 200, spark-submit passes --conf spark.sql.shuffle.partitions=300, and the session builder sets 600. Right before the only action, the code runs spark.conf.set("spark.sql.shuffle.partitions", 100). With adaptive execution off, how many tasks run in the stage after the groupBy?
> Back at Netflix, the viewing-hours job reads 50GB of play events, groups them by title and country, sorts by hours watched, and writes the result for the dashboard. A scheduler launches it with --deploy-mode cluster, --num-executors 10 and --executor-cores 4, adaptive execution is switched off in its configuration, and spark.sql.shuffle.partitions is left at 200. Tonight 1 stage sits at 199 of 200 tasks while the 6 a.m. deadline approaches.
groupBy and the sort make 2 boundaries, so the main job's plan has 3 stages. The sort also adds a sampling job ahead of it, and that job is where the read stage runs, leaving it skipped in the main job.groupBy, and the sort stage can't launch a task until that last task writes its output.spark.sql.shuffle.partitions with --conf or spark.conf.set. Adding slots means changing --num-executors or --executor-cores, and those take effect only at the next launch.filter is one, and so are select and withColumn.spark.sql.shuffle.partitions tasks, 200 by default.collect() lands and what survives a shutdown.spark-defaults.conf; executor resources are fixed at startup, while spark.sql. settings apply per query.Read a Spark job the way the scheduler does: jobs from actions, stages from boundaries, tasks from partitions.
- Category
- Spark
- Difficulty
- intermediate
- Duration
- 12 minutes
- Challenges
- 14 hands-on challenges
Topics covered: Job, Stage, Task, Why Stages Exist, Reading Parallelism, Where the Driver Lives, The spark-submit Config Surface
Lesson Sections
- Job, Stage, Task (concepts: Spark Execution Model)
3 nested units Counting the stages in one program Regrouping rows so that every key lands in 1 task is called a shuffle. The report below sums net revenue per region over 6 orders stored in 2 partitions, and each shuffle splits its output into 2 partitions. Before you press play, mark each call where you expect a stage to end. These counts assume adaptive execution is off. Spark 3.2 and later turn it on by default and can merge small partitions mid-job, a behaviour with lessons of its own. Why a
- Why Stages Exist (concepts: Spark Execution Model)
A task sees only its own partition Go back to stage 0 of the revenue report. Task 0 holds some orders and task 1 holds the rest, and either can hold US rows, so neither can produce the final US total alone. Spark settles this by ending the stage. Each task writes its partial sums to its executor's local disk, split into 1 piece per destination partition, and a fresh set of tasks fetches the pieces it owns from every executor. The Shuffles lessons take that write and fetch apart; this lesson need
- Reading Parallelism
Where a stage's task count comes from The next animation reads 9 page hits as 3 partitions, and its first line sets the regrouped side to 4 partitions. Count the tasks in each stage, and watch what each task of the second stage receives. Tasks against slots A stage's task count says how much work is queued. Your slots, 1 per executor core, say how much runs at once. With 10 executors of 4 cores, 40 tasks run together and the rest wait for a free slot. Each batch is a wave, and when tasks take si
- Where the Driver Lives
Client mode and cluster mode The driver is the process that splits your job into stages, hands tasks to executors and gathers results, so where it runs decides what survives a disconnect and where results land. Spark offers 2 places for it, chosen by the deploy mode. The link the driver talks over Checking from inside the program
- The spark-submit Config Surface (concepts: Configuration Tuning)
Every flag is a property Which value wins So the job gets 10 executors of 4 cores, which is 40 slots, and every stage after a boundary runs 800 tasks, which is 20 waves. Read once at startup, or per query Checking what applied