Spark jobs, stages and tasks: how to read the Spark UI
Spark turns each action, such as count or write, into 1 or more jobs. A job is split into stages wherever the data must be shuffled between machines, an Exchange in the plan, and each stage runs 1 task per partition of its data, in parallel on the executors' cores. The Spark UI, at localhost:4040 while a local session runs, lists the jobs, stages and tasks with their time and the bytes each shuffle moved.
Module 3: How Spark runs a job
At the end of this part you can:
- Say what makes a job, a stage and a task, and find the shuffles in a plan
- Read a query's jobs, stages and tasks in the Spark UI
- Explain skipped stages, and find the same information on Databricks serverless
Take this module into your AI
Open in ChatGPT (new tab)Open in Claude (new tab)Module 2's script cleaned 8 orders in about a second, and nothing about the run was worth looking at. Real pipelines read millions of rows, and when one is slow you need to know where its time went. This module makes the shop bigger, runs the same kinds of query on it and reads what Spark did, first in the Spark UI and then, in part 4, from a script.
Make a bigger shop
This script writes 2 million orders from 50,000 customers, as 8 Parquet files, and a customers table that gives each customer a country. Save it as make_shop.py and run it once:
"""Make a bigger shop: 2 million orders from 50,000 customers, as Parquet files."""
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = SparkSession.builder.master("local[4]").appName("make-shop").getOrCreate()
LAKE = "lake"
orders = spark.range(2_000_000).select(
F.col("id").alias("order_id"),
(F.col("id") % 50_000).alias("customer_id"),
((F.col("id") % 9_000) / 100).cast("decimal(10,2)").alias("amount"),
)
orders.repartition(8).write.mode("overwrite").parquet(f"{LAKE}/orders")
customers = spark.range(50_000).select(
F.col("id").alias("customer_id"),
(F.col("id") % 30).alias("country_id"),
)
customers.coalesce(1).write.mode("overwrite").parquet(f"{LAKE}/customers")
master("local[4]") runs Spark on your computer with 4 worker threads, as if it had 4 cores, whatever yours has. That keeps your numbers the same as the ones on this page. On Databricks Free Edition, leave out the builder line, since spark is already there, and save tables instead of folders: .saveAsTable("orders") and .saveAsTable("customers").
Driver, executors and tasks
A Spark application has 1 driver and some executors. The driver runs your Python, turns each query into a plan and hands out the work. Executors do the work, each on its cores. The data is cut into partitions, and the unit of work is a task: 1 task does 1 step of the plan on 1 partition. A core runs 1 task at a time, so with 4 cores, 4 tasks run at once and the rest wait their turn.
On local[4] the driver and the executor are 1 process on your computer. On a cluster they are separate machines, but the plan, the jobs, the stages and the tasks are the same.
An action starts a job, a shuffle ends a stage
Save this as runs.py and run it. The last line keeps Spark running so you can look at it:
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = SparkSession.builder.master("local[4]").appName("how-spark-runs").getOrCreate()
orders = spark.read.parquet("lake/orders")
customers = spark.read.parquet("lake/customers")
print(orders.filter("amount > 50").count())
by_customer = orders.groupBy("customer_id").agg(F.sum("amount").alias("revenue"))
by_customer.explain()
by_customer.write.mode("overwrite").parquet("lake/revenue_by_customer")
input("The Spark UI is at http://localhost:4040. Press Enter to stop Spark.")
887778
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[customer_id#1L], functions=[sum(amount#2)])
+- Exchange hashpartitioning(customer_id#1L, 200), ENSURE_REQUIREMENTS, [plan_id=63]
+- HashAggregate(keys=[customer_id#1L], functions=[partial_sum(amount#2)])
+- FileScan parquet [customer_id#1L,amount#2] ...
Read the plan from the bottom. Each task reads its part of the files and sums each customer's orders in that part, partial_sum. A customer's orders are in every part, though, so to finish the totals Spark must bring each customer's partial sums together. That is the Exchange: every task writes its rows out by customer_id into 200 buckets, and the next tasks each read 1 bucket from all of them. Moving rows between tasks like this is a shuffle, and it is where a job is split into stages, since nothing after it can start until everything before it has written.
count, write, show and collect are actions, and each starts work. The 2 actions here run as 4 jobs, as the UI shows.
Read the Spark UI
Open http://localhost:4040 while the script waits. Start with the SQL / DataFrame tab: it lists each query an action ran, here a count and a parquet write, with the ids of the jobs it ran. Open the write to see its plan as a graph, with the rows that passed through each operator.
The Jobs tab lists 6 jobs. The first 2, with 1 task each, came from spark.read.parquet, which opens a file to learn the schema. The count ran jobs 2 and 3, and the write jobs 4 and 5. Their names, like $anonfun$withThreadLocalCaptured$1, say nothing useful, which is why the SQL tab is the better place to start.
The Stages tab holds the detail. For the write:
| Stage | Tasks | Shuffle write | Shuffle read |
|---|---|---|---|
| 5 | 4 | 3.3 MB | |
| 6, skipped | 4 | ||
| 7 | 3 | 3.3 MB |
Stage 5 read the files in 4 tasks and wrote 3.3 MB of partial sums. Stage 7 read them back and wrote the result. Open a stage and its summary table shows the time and bytes of the quickest task, the median and the slowest: a slowest task far above the median is the first sign of skew, which part 3 takes apart.
Choose a query in the demo and turn adaptive query execution on and off. The numbers are from these runs.
orders.groupBy("customer_id").agg(F.sum("amount")).write.parquet(…)
Jobs
Tasks run
Shuffled
Job 1
4 tasks
Read, sum each customer's part
writes 3.3 MB
Job 2
Skipped
Read, sum
3 tasks
Final sums, write the files
reads 3.3 MB
2 jobs and 2 stages ran, 7 tasks in all. Adaptive query execution runs each stage that ends in a shuffle on its own, measures what it wrote, then plans the rest, so a later job lists the stages before it as skipped (1 here): they ran once, and their output was reused. The last stage has 3 tasks, not 200, because Spark merged the small partitions. With it off, the same query runs 204 tasks.
2 million orders on 4 cores (local[4]), as Spark 4.2 ran them, from its REST API. Shuffle bytes are what each stage wrote for the next and read from the one before.
Why a stage says skipped
The plan says AdaptiveSparkPlan isFinalPlan=false: Spark has not finished planning. Adaptive query execution runs each stage that ends in a shuffle on its own, as a job, measures what it wrote, then plans the rest. That is why stage 7 has 3 tasks, not the 200 of the plan: 3.3 MB did not need 200 partitions, so Spark merged them.
The second job needs stage 5's output, so it lists stage 5 again, as stage 6, and marks it skipped: its output was already on disk, so no task ran. Skipped stages are normal and cost nothing. With adaptive query execution off, the same write is 1 job of 2 stages, with 200 tasks in the second and 200 files at the end.
On Databricks serverless
Serverless compute has no Spark UI. Databricks points you to the query profile instead: under a cell that ran a query, choose See performance, pick the statement, then See query profile. It draws the query's operators as a graph with the time, rows and peak memory of each, and lists the most expensive ones. It answers the same question, which step took the time, without stages and tasks to read. Classic compute, which Free Edition does not have, keeps the Spark UI.
Part 2 explains where the 4 tasks of the first stage and the 200 of the shuffle come from, and when to change them.
Check yourself
2 questions, each in a new situation
1. orders.groupBy("customer_id").agg(F.sum("amount")) is written to Parquet. Why does Spark run it in 2 stages, not 1?
Not quite. The first stage reads the files and sums each customer's orders within each partition. To finish a customer's total, all of their partial sums must be in 1 place, so Spark writes them out by customer_id and the next stage reads them back, the Exchange in the plan. The write happens inside the last stage; the driver plans the work but does not add up the data.
2. The Stages tab shows a stage with 4 tasks as skipped, in a job that succeeded. What happened?
Not quite. With adaptive query execution, Spark runs each stage that ends in a shuffle as a job of its own, measures what it wrote, then plans and runs the rest as a new job. That new job lists the earlier stage because it depends on it, but its output is already on disk, so it shows as skipped with no tasks run. Nothing is lost.
Questions
- What is the difference between a job, a stage and a task in Spark?
- A job is the work Spark does for 1 action, such as count or write. Spark splits the job into stages at every shuffle, where rows must move between machines, so the stages run one after another. A stage runs as many tasks as its data has partitions, each task doing the same work on 1 partition, in parallel on the executors' cores.
- How do I open the Spark UI for local PySpark?
- Open http://localhost:4040 while the Spark session is running; a second session on the same computer takes 4041, and so on. The UI closes when the session stops, so keep the script waiting, for example with input(), or set spark.eventLog.enabled to true and read old runs in Spark's history server.
- Why are some stages skipped in the Spark UI?
- A skipped stage is one that the job depends on but did not need to run, because its shuffle output already existed from an earlier job. Adaptive query execution makes this common, since it runs each stage that ends in a shuffle as its own job and then plans the next. A skipped stage is not an error.
- Is there a Spark UI on Databricks serverless?
- No. Serverless compute does not show the Spark UI; Databricks points you to the query profile instead. Under a notebook cell that ran a query, choose See performance, pick the statement, then See query profile, which shows the query's operators as a graph with the time, rows and memory of each, and a list of the most expensive.
Sources