← WritingData Engineering

How to read the Spark UI

2026-09-05 · 15 min read

sparkpysparkdata-engineeringperformance

Every PySpark job produces two outputs. One is the result you asked for. The other is a running account of how Spark got there: how it cut your code into jobs, where it moved data, which joins it chose, how long each piece took. That account is served as a web page by your own SparkSession, on by default, at http://localhost:4040. In my experience it is the single highest-leverage thing a new Spark engineer can learn to read, and the thing most people skip.

I am going to walk you through it the way I do for engineers joining my team. Not every tab, just the five screens that answer real questions, what to look at on each, and how to turn what you see into a decision. If you are new to Spark, read it top to bottom. If you have been writing Spark for a year and have never opened the SQL tab, skip to Screen 3, then come back.

You do not need a cluster. Everything below ran on my laptop with local[*], Spark 3.5.2, and a dataset shaped like the retail_db tables used in most Spark courses: 60,000 orders, 132,000 order lines, 1,345 products, 40 categories. Start a session, open port 4040 in another tab, and follow along.

The vocabulary the UI is organised around

Spark has its own words for units of work, and every screen in the UI is a view over one of them. Get these five straight and the rest of the UI stops looking arbitrary.

Keep that hierarchy in mind. The Jobs tab lists jobs, the Stages tab lists stages, and each stage page lists its tasks. When something is slow, you descend that tree until you find the level where the time is.

The query behind the screenshots

completed = orders.where(F.col("status").isin("COMPLETE", "CLOSED"))
 
revenue = (items.join(completed, "order_id")
                .join(F.broadcast(products.select("product_id", "category_id")), "product_id")
                .join(F.broadcast(categories.withColumnRenamed("name", "category")), "category_id")
                .groupBy("category")
                .agg(F.round(F.sum("subtotal"), 2).alias("revenue"),
                     F.countDistinct("order_id").alias("orders"))
                .orderBy(F.desc("revenue")))
revenue.show(10)

Revenue per category over completed orders: filter orders, join order lines to them, attach product and category, aggregate, sort. It is a deliberately ordinary query, because the point is to show you how much the UI reveals about ordinary code. I made three choices on purpose so that specific things would show up on the screens: I wrote the first join as if both sides were large, I added two manual broadcast() hints, and I set spark.sql.shuffle.partitions to 8. The same script also ran a word count with the RDD API and cached a frame at the end, so those appear too.

Screen 1 — Jobs: how your code was cut up

Spark UI Jobs tab listing 18 completed jobs, several marked "1 skipped" or "2 skipped"

The Jobs tab is the landing page. The header shows the user, session uptime and the count of completed jobs. Below it, one row per job, newest first.

Start with the count. The script has perhaps six lines that do anything, and the tab shows eighteen jobs. Every show(), count() and first() is an action, and every action is a job. A show() on a sorted DataFrame is frequently two or three jobs, because Spark samples the data first to choose range boundaries for the sort. This is the first lesson I want you to take from the Jobs tab: the count() calls people sprinkle through a pipeline "to check" are not free. Each one is a full job, and in a long pipeline they routinely double the runtime.

The Description column names the action that triggered the job, and links to the job's page. Duration is where you find the expensive ones. Stages: Succeeded/Total and Tasks tell you how much work each job represented.

Now the rows marked 1/1 (1 skipped) or 1/1 (2 skipped). A skipped stage is good news. It means Spark had already computed that stage's output for an earlier job and reused the shuffle files it left on disk instead of recomputing. Nobody asks for this, it is automatic, and it is why two actions on the same aggregated DataFrame cost much less than double.

What to check: the number of jobs against the number of actions you intended, the longest duration, and any job with an unexpectedly large task count.

Screen 2 — the DAG: where the shuffles are

DAG visualisation of the word-count job: Stage 4 greyed out and marked skipped, Stages 5 and 6 each containing a partitionBy and mapPartitions

Open a job and expand DAG Visualization. A DAG is a directed acyclic graph: boxes and arrows with no loops. Each large rounded box is a stage, and each arrow between stages is a shuffle. This is the fastest way to see how many times a job moves data.

This DAG is the word count: reduceByKey, then sortBy, then take(3). Read it left to right.

Two shuffles is the correct minimum for count-then-sort. The lesson is not that this job is wrong. It is that the DAG makes the shuffle count visible, and shuffle count is the single best first-order predictor of Spark job cost. Each boundary means every row is serialised, written to local disk, sent across the network and read back. When a job is slow, count the boxes before you look at anything else.

What to check: the number of stages, which are skipped, and whether a shuffle has appeared that your code did not obviously ask for.

Screen 3 — SQL / DataFrame: what Spark actually decided

SQL tab physical plan showing two CSV scans, a filter to 27,145 rows, a BroadcastExchange, and a BroadcastHashJoin producing 59,902 rows

If you write DataFrame or SQL code, this is the most important tab in the UI, and the one I see intermediate engineers neglect most. It lists every query; open one to see its physical plan, the concrete sequence of operators Spark chose. Your code states intent. The plan shows what Spark did about it, and the two are often different. This query is a good example.

Read the graph top down. Each box is an operator and carries its output row count.

Row counts are the first thing I read on any plan, because they are a free correctness test. Had the join produced 132,269 rows, the filter was not applied. Had it produced far more, the join key was not unique on one side. You get this at every operator without adding a single count() to your code.

Then the timings. BroadcastExchange reports time to collect: 6.3 s. To broadcast, the driver has to collect the rows to itself before distributing them, and that step is single-threaded on one machine. Six of this query's 25 seconds went there. The WholeStageCodegen boxes, where the filtering and joining actually execute, each took under 4 seconds. Anyone who set out to "optimise the join" here would be optimising the cheap part. The plan tells you where the time is; your intuition about the code usually does not.

What to check: row counts at each operator against what you expected, which join strategy Spark selected, and which operator holds the largest share of wall time.

Screen 4 — Stages: tasks, skew, and adaptive execution

Stage detail page for Stage 12: an AQEShuffleRead feeding WholeStageCodegen and an Exchange, summary metrics for 1 completed task, shuffle read 922.5 KiB / 58,539 records, shuffle write 1.8 KiB / 40 records

Open any stage and you get this page. Three parts of it matter.

The header gives stage totals: aggregate task time, shuffle bytes read and written. This stage read 922.5 KiB / 58,539 records and wrote 1.8 KiB / 40 records. Forty is the number of categories, so this is the aggregation stage.

Summary Metrics is the table in the middle: min, 25th percentile, median, 75th and max across tasks for duration, GC time, and shuffle read and write. This table is how you diagnose skew, the most common Spark performance problem in production. When the max duration sits far above the median, one partition holds a disproportionate share of the data and one task is doing most of the work while every other core idles. On this page every column is identical, and the reason is the third thing to notice.

The line reads Summary Metrics for 1 Completed Tasks. I set spark.sql.shuffle.partitions to 8, so a reader who knows that setting would expect eight tasks. The top box of the stage DAG explains the discrepancy: AQEShuffleRead. Adaptive Query Execution is on by default from Spark 3.2. After the shuffle, it looked at the real output size, judged that 922 KB split eight ways was wasteful, and coalesced the partitions into one before running the aggregation. That is the right call for this data. It is also why, when you change shuffle.partitions and observe no effect, you are not imagining it: AQE is overriding you, and the stage page is where you confirm it.

What to check: task count against expectation, and the spread between median and max in the summary table. A max more than two or three times the median is skew worth investigating.

Screen 5 — Storage: what caching actually kept

Storage tab showing one cached RDD at storage level Disk Memory Deserialized 1x Replicated, 1 partition, 100% cached, 1678.1 KiB in memory

The Storage tab stays empty until you call .cache() or .persist() on something and then run an action on it. cache() alone does nothing observable. It is a transformation like any other, and the data is materialised only when a job computes it.

After the revenue query, the script cached the joined frame and counted it. The tab shows one entry, storage level Disk Memory Deserialized, one cached partition, 100% cached, 1.7 MB in memory.

Three facts this screen makes concrete that people frequently get wrong. First, cache() on a DataFrame means memory and disk. If the data does not fit in memory, the remainder spills to disk rather than being evicted. Second, the partition count is one, because AQE coalesced the join output. Anything you do next with this cached frame runs as a single task until a shuffle redistributes it, which is a genuine performance trap when the cached frame is large. Third, the long text in the name column is the plan that produced the cached data. When you have several cached frames, that text is how you map each entry back to your code.

What to check: whether the thing you cached is present at all (did an action run after the cache call?), its partition count, and whether the cached fraction is 100%.

The order I check things in

When a job is slow or wrong, this is the sequence. It is deliberately top down, from the cheapest observation to the most detailed.

  1. Jobs tab. Are there more jobs than actions I intended? Which one is slow?
  2. SQL tab, row counts. Do the counts at each operator match what the filters and joins should produce? Wrong counts mean a logic bug, and no amount of tuning fixes a logic bug.
  3. SQL tab, join strategy and timings. Did Spark broadcast what I expected? Which operator owns the time?
  4. Job DAG. How many shuffles, and is any of them avoidable by reordering or pre-aggregating?
  5. Stage page for the slow stage. Task count, median versus max, spill. This is where skew lives.
  6. Storage. Only if I cached something: is it actually cached, and with how many partitions?

Most problems resolve at step 2 or 3. Engineers who go straight to step 5 and start tuning partition counts usually end up tuning around a bug.

What changes on a real cluster

Everything above transfers directly, with four differences worth knowing before you open the UI on a production job.

The UI dies with the session. It is served by the running SparkSession. On a laptop, keep the session alive while you read. On a cluster, enable spark.eventLog.enabled and point a History Server at the log directory, and the same pages remain available after the job finishes. If your platform (Databricks, EMR, Dataproc) provides a Spark UI link on completed jobs, that is the History Server.

Ports increment. Each session on a machine wants 4040; the second gets 4041, the third 4042. spark.sparkContext.uiWebUrl gives you the address without guessing.

Local mode has one executor, named driver. The Executors tab will show a single row for local[*], because the driver executes tasks itself. On a cluster you see one row per executor, and that tab becomes useful: it is where you find executors that are being lost, or one executor with far more GC time than the others.

Absolute timings on a laptop are misleading. JVM and Python worker startup dominate small jobs, particularly on Windows. The 25-second query above would take a few seconds on a modest Linux cluster. What carries over is the shape: row counts, join strategy, shuffle count, and which operator is expensive relative to the others. Read the UI for proportions, not seconds.

Exercises

These are the exercises I set for people learning this. Each one is designed to make one screen click.

  1. Run any DataFrame query with a groupBy and a show(). Count the jobs. Call show() again and look for a skipped stage.
  2. Open the SQL tab for that query and read the row counts from the scans down to the final operator. Confirm each filter and join did what you intended.
  3. Join a large frame to a small one and note the join strategy in the plan. Then set spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1) and rerun. You should see BroadcastHashJoin become SortMergeJoin, an extra shuffle appear in the DAG, and the duration change.
  4. Cache a frame. Open the Storage tab before and after calling count() on it.
  5. Set spark.sql.adaptive.enabled to false, rerun the aggregation, and compare the task count on the stage page with what you saw under AQE.

Glossary

Share