It Ran in 10 Minutes Yesterday: How to Debug a Slow Spark Job and Fix Data Skew

“It ran in 10 minutes yesterday. Today it takes 45.” Every data engineer has read that message at 9 a.m., and interviewers know it. The question sounds like a small follow-up after a big design round, but it quietly tests almost everything: how Spark actually runs your code, whether you can read the Spark UI, whether you know what data skew looks like, and whether you fix things with evidence or with a bigger credit card.

This post shows how to debug a slow Spark job the way a strong senior data engineer would, and how to explain it in an interview. Not as a list of tuning flags, but as a debugging story: how Spark runs your code, how to read the Spark UI, and how to find and fix data skew in Spark, with real Spark UI screenshots from a job we deliberately broke and then fixed four different ways.

By the end you’ll be able to:

  • open your answer with a first line that sets the pace of the whole round;
  • explain how Spark turns code into jobs, stages, shuffles and tasks, and why one task can hold up a whole stage;
  • walk the Spark UI screen by screen and prove data skew with numbers;
  • choose between AQE, broadcast, salting and hot-key isolation, and explain the trade-offs;
  • rule out the other usual suspects and stop the problem from coming back.

IN A HURRY? THE ANSWER IN FOUR LINES

  1. Open with the constraint, a method and a hypothesis: “No extra hardware. I’ll find what changed and where the time goes. 4.5× slower on 3× data is non-linear.”
  2. Prove it in the Spark UI: slowest stage → task quantiles. Max ≫ median in time and shuffle read = data skew.
  3. Fix it cheapest first: AQE on, broadcast the small side, then isolate or salt the hot key; filter junk keys.
  4. Verify and prevent: same metrics before/after, same output, alerts on runtime and top-key share.

Our candidate is Nikhil again (you may have met him in One Ride, 400 Milliseconds and The Vendor Who Pushes). Same system-design practice session with friends, second half of the round. The design is done; now the interviewer leans back and asks about Spark.


Table of Contents

The question, and why most answers go wrong

Comic: the interviewer asks why a Spark job went from 10 to 45 minutes with no extra budget; most candidates list tools; Nikhil opens with the cost constraint and a plan to find what changed and which stage slowed down

“Your daily Spark job has run in about 10 minutes for months. Since yesterday it takes 45. The data has grown. You’re not allowed to add nodes, bigger instances or any extra cost. How do you find out what’s wrong, and how do you fix it?”

Three constraints hide in that prompt, and each one closes a lazy exit:

  • “Since yesterday”: something changed. Your first job is to find out what, not to start tuning.
  • “The data has grown”: a tempting explanation, and often only half the story. If data grew 3× and runtime grew 4.5×, something non-linear is going on.
  • “No extra cost”: “add executors” and “use bigger nodes” are off the table. You have to make the same hardware do the work better.

Here’s what interviewers are really scoring:

What they’re checkingWeak signalStrong signal
MethodJumps to a fix (“increase memory”)A repeatable loop: change → stage → evidence → cause → fix → verify
Spark internals“Spark is distributed”Explains jobs, stages, tasks, shuffles and why one task can hold up a stage
Tooling“I’d check the logs”Knows which Spark UI tab answers which question
Skew“Use salting”Detects skew from task quantiles, then picks among AQE, broadcast, salting, splitting hot keys
Cost awarenessAdds hardwareMakes the same cluster faster and explains why that works
PreventionStops at the fixAdds alerts and checks so it doesn’t happen again

THE INTERVIEWER ASKS

Why not just add more executors? It’s the fastest fix.

What they’re testing: whether you understand that parallelism has a ceiling.

Strong answer: “Besides the budget rule, it may not even help. A stage can’t finish before its slowest task, and one task runs on one core. If one partition holds most of the data, more executors just means more idle cores waiting for that one task. I’d first find out whether the work is evenly slow or unevenly slow. That decides everything.”

The first 60 seconds: how to open your answer

Most candidates think the hard part of this question is Spark. It isn’t. The hard part is the first sentence, because it decides who leads the next 20 minutes. Open with a fix and you spend the round defending a guess. Open with a vague “I’d check the logs” and the interviewer takes over with their own checklist. Open with a clear, evidence-first plan and the interviewer tends to follow your agenda, which means you get to talk about the things you know best.

The first 60 seconds of the answer: three weak first lines and why they fail, the three parts of a strong first line (constraint, method, hypothesis), five clarifying questions and a 15-minute pacing plan

Here is how Nikhil opens. Read it out loud once; it takes about 20 seconds:

“Since the cost is fixed, I won’t reach for more hardware. I’ll first find out what changed and then where the time goes, stage by stage, in the Spark UI. One thing I’d check early: the runtime grew 4.5 times. If the data grew by less than that, something non-linear is going on, like data skew, spill or a join plan that changed, not just more volume.”

Three sentences, three jobs:

PartWhat he saysWhat the interviewer hears
Constraint“I won’t reach for more hardware.”He listened. The lazy answer is off the table before they have to take it away.
Method“What changed, then where the time goes, stage by stage.”He has a process. I can let him drive.
Hypothesis“4.5× time… non-linear… skew, spill or a changed join.”He knows how Spark behaves. Let’s see if he can prove it.

Notice what he did not say: no tool names, no config flags, no “I would salt it”. The hypothesis is phrased as something to check, which keeps him honest if the evidence points elsewhere.

Then ask a few sharp questions

Right after the opener, spend about a minute on clarifying questions. Each one should be able to change your path:

QuestionWhy it matters
Is the whole job slower, or one step?Narrows the search from the whole pipeline to one query or stage.
How much did the data grow?3× data and 4.5× time is non-linear; 5× data and 4.5× time is just volume.
Did anything deploy, or any config change?Code and config changes are the most common “since yesterday” causes.
Same cluster, same schedule, same neighbours?Rules resource contention in or out.
Do we have yesterday’s Spark UI or event logs?A good run to compare against is the fastest debugging tool there is.

In our practice session the interviewer answers: “One step, the daily report. The data roughly tripled. No deploy. Same EMR cluster. Yes, event logs are kept.” Nikhil now has his first clue: 3× data, 4.5× time.

Pace the rest of the answer

Say the plan once, then follow it: about 1 minute to open, 2 to clarify, 5 on evidence in the Spark UI, 4 on the cause and fixes, and 3 on verification and prevention. If the interviewer interrupts, answer and come back to the plan (“…so, back to the stage metrics”). That sentence alone shows you’re in control of the conversation.

💡 Interview tip: If you blank on the opener, fall back on its skeleton: “I won’t add cost. I’ll find what changed and where the time goes. My first suspect is X, and here’s how I’d confirm it.” It works for almost any performance question: slow SQL, a slow dashboard, a slow API.

How to answer: the six-step debugging loop

Flow diagram of the six-step Spark debugging loop: what changed, find the slow stage, read the evidence, name the cause, fix cheapest first, verify and prevent

The opener promised a method; this is it. Every step produces something you can show the interviewer:

“I’ll check what changed between yesterday and today. Then I’ll open the Spark UI for both runs and find which stage got slower. Inside that stage I’ll look at the task metrics to tell volume from skew from spill. Once I can name the cause, I’ll fix it the cheapest way first (configuration, then the join strategy, then the data, then the code) and compare the same metrics after the fix.”

Step one deserves its own checklist, because “what changed?” has more answers than people expect:

What could have changedHow to check quicklyTypical effect
Input volumeInput size / rows in the Stages tab, or count() per dayLinear slowdown (3× data ≈ 3× time)
Key distributionTop-N keys by count on the join / group keyOne huge task, non-linear slowdown
Number and size of input filesFile listing, “number of files read” in the SQL tabThousands of tiny tasks, slow listing
Code / queryGit history, the deployed versionNew join, UDF, collect(), lost filter
ConfigurationEnvironment tab, spark-defaults, job parametersAQE or broadcast disabled, fewer partitions
Small table grew past a limitPlan: BroadcastHashJoin yesterday, SortMergeJoin todayA cheap join became a full shuffle
Cluster / neighboursExecutors tab, YARN / cluster metrics, spot interruptionsFewer executors, lost nodes, retries
Upstream data qualityNull / default keys, duplicatesJoin explosion, a giant “NULL” partition
File format / compressionA new large .gz or other non-splittable fileOne input task reads the whole file

💡 Interview tip: Keep yesterday’s Spark UI. With spark.eventLog.enabled=true and spark.eventLog.dir on durable storage (S3, HDFS, DBFS), a Spark History Server can reopen the UI of any finished run. EMR and Databricks do this for you for a retention period. Comparing a good run with a bad one is the fastest debugging tool you have.

Spark architecture in ten minutes

You can’t read the Spark UI without a mental model of what Spark is doing, and interviewers often probe it here (“what’s a stage?”). This is the minimum you need, explained with our job.

Driver, cluster manager and executors

Spark architecture diagram: driver, cluster manager, and three executors with four cores each, memory and shuffle between executors
  • Driver: runs your main program and the SparkSession. It turns your DataFrame code into a plan, splits it into jobs, stages and tasks, and schedules tasks on executors. It doesn’t process the bulk data itself, unless you ask it to with collect() or toPandas().
  • Cluster manager: YARN on EMR, Kubernetes, Spark standalone, or Databricks’ own. It hands out containers; it knows nothing about your query.
  • Executors: JVM processes on worker nodes. Each has a number of cores (task slots) and some memory. They run tasks, keep cached data, and write shuffle files to local disk.

The rule that matters most for this question: one task slot runs one task at a time, and one task processes one partition. By default a slot is one core (slots per executor = spark.executor.cores ÷ spark.task.cpus). So your parallelism is min(number of partitions, total task slots), and a single oversized partition is processed by a single slot, one core, no matter how big the cluster is.

Application, jobs, stages and tasks

Diagram showing code turning into one job with four stages split at shuffles, and 32 tasks inside the join stage with one hot task holding 92 MiB
LevelCreated byIn our demo
ApplicationOne SparkSession / spark-submitslow-job-demo
JobEach action: write, count, collect, show… Some actions add extra jobs: sampling for orderBy, schema inference, collecting a broadcast table, and AQE, which runs stages as separate jobsThe report write = 1 job without AQE; 4 jobs with AQE on
StageA chain of narrow operations; a new stage starts at every shuffleRead orders · read customers · join + partial aggregate · final aggregate
TaskOne per partition in the stage32 shuffle partitions → 32 tasks in the join stage

Spark is lazy: join and groupBy only build a plan. Nothing runs until an action, which is why a “slow line of code” in your notebook is almost always the action at the end, not the line that looks expensive.

Narrow vs wide transformations (and why shuffles matter)

NarrowWide (shuffle)
Examplesselect, filter, withColumn, map, unionjoin (non-broadcast), groupBy, distinct, repartition, orderBy, window functions
Data movementNone: each output partition depends on one input partitionRows move across the network so that equal keys meet in the same partition
CostCheap, pipelined inside one stageWrite to local disk, send over the network, read, often sort
Where skew bitesRarelyHere. All rows with the same key land in the same partition

What a shuffle actually does

Shuffle internals: three map tasks write shuffle files partitioned by hash of the key, and three reduce tasks fetch their partition from every map output; the hot key's partition is four times bigger

Every Exchange node in a query plan is a shuffle, and it’s the most expensive thing Spark does:

  1. Map side: each task of the earlier stage computes hash(key) % numPartitions for every row, sorts its output by target partition, and writes one data file plus an index file to local disk. The UI shows this as Shuffle Write.
  2. Reduce side: each task of the next stage fetches its block from every map output over the network (Shuffle Read, plus fetch wait time).
  3. Failure behaviour: if an executor dies, its shuffle files die with it and the map tasks that wrote them must re-run, unless an external shuffle service keeps serving them (standard on YARN/EMR with dynamic allocation). Lost Spot nodes during a run therefore show up as re-run stages.

Which brings us back to the last row of the narrow-vs-wide table, the whole story of this post: a shuffle sends every row with key C-0000001 to the same partition, because it hashes the key. If that key owns 45% of the rows, one task gets 45% of the work.

From code to plan: Catalyst and AQE

Flow from code to logical plan, Catalyst optimizer, physical plan and execution, with Adaptive Query Execution re-planning after each shuffle stage

Spark’s Catalyst optimizer rewrites your query (pushing filters down, pruning columns) and picks a physical plan, including the join strategy. By default it broadcasts a table estimated below spark.sql.autoBroadcastJoinThreshold (10 MB) and otherwise uses a sort-merge join, which shuffles and sorts both sides.

The join strategies you’ll meet in plans:

StrategyWhen Spark picks itShuffle?
Broadcast hash joinOne side estimated under the broadcast threshold (10 MB), or a broadcast hintNo, for the big side
Sort-merge joinDefault for large equi-joins (spark.sql.join.preferSortMergeJoin=true)Both sides, plus a sort
Shuffled hash joinWhen sort-merge isn’t preferred, or AQE converts to it at runtime; the build side per partition must fit in memoryBoth sides, no sort
Broadcast nested loop join / cartesian productNon-equi joins (<, between, OR conditions); cartesian product if neither side can be broadcastCompares every pair of rows: a classic hidden trap

Adaptive Query Execution (AQE), enabled by default since Spark 3.2, goes further: after each shuffle stage finishes it looks at the real partition sizes and re-plans the rest of the query. It can:

  • merge small shuffle partitions (coalescing);
  • switch a sort-merge join to a broadcast join (spark.sql.adaptive.autoBroadcastJoinThreshold), reading the already-written shuffle files locally with a local shuffle reader;
  • switch a sort-merge join to a shuffled hash join when partitions are small enough (spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold, which is 0, i.e. off, by default);
  • split skewed partitions in joins, and in REBALANCE hints.

The WholeStageCodegen (n) boxes you’ll see in the UI are Tungsten at work: Spark fuses a chain of operators into one generated Java function. A stage can contain several such blocks (our join stage has three), and the operators inside one block share a single duration metric.

Partitions: the unit of parallelism

WhereWhat decides the numberDefault / rule of thumb
Reading filesspark.sql.files.maxPartitionBytes, file sizes and count128 MB per partition; many tiny files = many tiny tasks
After a shufflespark.sql.shuffle.partitions, or AQE coalescing200 by default; AQE targets advisoryPartitionSizeInBytes (64 MB)
WritingPartitions of the final DataFrame, repartition / coalesceAim for files of roughly 128 MB–1 GB

A healthy stage has tasks of similar size, big enough to amortise overhead (tens to hundreds of MB), and at least as many tasks as cores. For the full list of knobs, see our Apache Spark performance tuning and best practices guide. This post focuses on how to diagnose which knob matters.

Executor memory and spill

Executor memory layout: reserved, user, storage and execution memory, overhead, and what spill means and how to reduce it without more memory

Joins, sorts and aggregations use execution memory. When one task’s sort buffer or hash table doesn’t fit, Spark doesn’t crash; it spills: writes sorted runs to local disk and merges them later. Spill is not an error, but it adds serialisation and disk I/O. When spill shows up in only one task, it’s almost always because that task’s partition is far bigger than the rest. Remember that pattern; the UI will show it to us in a minute.

For PySpark, Python worker memory comes out of the container’s overhead unless you set spark.executor.pyspark.memory; containers killed by YARN or Kubernetes for “exceeding memory limits” are usually an overhead problem, not a heap problem.

THE INTERVIEWER ASKS

What’s the difference between a job, a stage and a task?

What they’re testing: whether you can connect code to what runs on the cluster.

Strong answer: “An action like write starts a job. The job is split into stages at shuffle boundaries, because a shuffle needs all of the previous stage’s output before the next can start. Each stage runs one task per partition, and each task runs on one core. So the stage’s runtime is roughly its slowest wave of tasks, and a single huge partition becomes a single slow task.”

THE INTERVIEWER ASKS

Is spill bad? Should I just give executors more memory?

What they’re testing: whether you treat symptoms or causes.

Strong answer: “Spill is a symptom, not a failure. First I check where it is. If every task spills a bit, partitions are too big: more shuffle partitions or AQE fixes it for free. If one task spills a lot, it’s skew, and more memory would only hide it while that task stays slow. Memory is the last lever, and here it costs money anyway.”

The demo: yesterday vs today, reproduced

To make the screenshots real, we reproduced the incident on a small scale with PySpark 4.0 on a laptop. The “daily report” joins orders to customers and aggregates revenue by region and segment:

def report(orders, customers):
    return (orders.join(customers, "customer_id")
                  .groupBy("region", "segment")
                  .agg(F.count("*").alias("orders"), F.sum("amount").alias("gmv")))

spark.sparkContext.setJobDescription("2 · Today: 24M orders, 45% one customer (SKEW)")
report(today, customers).write.format("noop").mode("overwrite").save()
  • Yesterday: 8M orders spread evenly over 200,000 customers.
  • Today: 24M orders (3× growth), and 45% of them belong to one customer, C-0000001. In real life this is a marketplace’s “guest checkout” account, a default ID from a buggy upstream release, or one enterprise client onboarding overnight.
  • To mimic a legacy job, AQE and automatic broadcast are switched off, and shuffle partitions are set to 32.
RunDataQuery time
Yesterday8M orders, no skew19 s
Today (the incident)24M orders, 45% one customer31 s
Control24M orders, no skew30 s
Fix A: AQE skew joinToday’s data27 s
Fix B: salting (N = 16)Today’s data26 s
Fix C: broadcast joinToday’s data15 s
Recommended: AQE + broadcastToday’s data13 s

Why the laptop hides the skew (and why that’s a lesson)

Look at “Today” vs “Control”: 31 s vs 30 s. If you only looked at wall-clock time, you’d blame volume and move on. That’s exactly the trap.

The reason is one formula worth memorising:

stage time ≈ max( slowest task , total task time ÷ task slots )

Gantt charts of the demo's 32 tasks on 4 slots and on 32 slots, with and without skew: on 4 slots both take about 16 to 17 seconds, on 32 slots the skewed stage takes 12.2 seconds versus 3.3 seconds

Our demo machine has only 4 task slots (on 2 CPU cores). The join stage’s 32 tasks run in about 8 waves, the second term of the formula dominates, and the skewed and even runs take about the same time. On a real cluster with 32+ slots, all tasks run at once, the first term takes over, and the stage takes as long as its slowest task: about 12 s skewed vs about 3 s without skew. That’s a 4× difference in the join stage from the same data volume. Scale that up to production data and you get 10 minutes → 45 minutes.

The same formula answers “why not add executors?”: more slots only shrink the second term. They can never push the stage below its slowest task.

The lesson Nikhil says out loud: wall-clock time tells you that something is slow; task-level metrics tell you why. So let’s open the UI.

How to read the Spark UI, tab by tab

The Spark UI runs on the driver (port 4040 while the application is alive) and in the Spark History Server afterwards. Nikhil walks it top-down: query → job → stage → task → plan, asking one question per screen and stopping as soon as he can name the cause.

Map of reading the Spark UI: SQL tab, job DAG, stage metrics, task list and SQL plan with environment, each with the question to ask, what to look at and the red flag, plus the meaning of each event timeline colour
TabThe question it answers
SQL / DataFrameWhich query got slower? What plan did it actually run?
JobsWhich stages make up that query, and which one takes the time?
StagesInside the slow stage: are tasks evenly slow, or is one task the problem?
ExecutorsAre resources healthy? GC, failed tasks, lost executors, one bad node?
EnvironmentWhich settings did this run really use?
StorageWhat’s cached, and is it taking memory the job needs?

SQL / DataFrame tab: which query got slower

Spark UI SQL tab listing seven queries with durations: yesterday 19 seconds, today with skew 31 seconds, no-skew control 30 seconds and the recommended fix 13 seconds

Each row is one query with its duration and the job IDs it triggered. In a real pipeline with dozens of steps, this is where you find the one step that grew. Naming steps with setJobDescription() (or spark.sql query comments) makes this page readable at 9 a.m.

Jobs tab: the DAG and the slow stage

Spark UI job details page with the DAG visualization, an Exchange node marking a shuffle, stage 6 highlighted, and the completed stages table showing stage 6 took 18 seconds

The DAG shows the stages and the Exchange (shuffle) nodes between them. The stages table below it is where you spot the offender: stage 6, the join, with 32 tasks and 18 s. Click it.

Two more things to check on this page: skipped stages (normal: output reused from an earlier job) and retried stages or failed tasks (not normal: fetch failures or lost executors, for example Spot reclaims, forcing recomputation). “It got slower today” is sometimes simply “it ran part of the job twice today”.

Stages tab: task quantiles tell the truth

Spark UI stage 6 with event timeline showing one very long task, and summary metrics where median duration is 1 second but max is 12 seconds, max shuffle read 92.6 MiB and spill only in the max task

This is the most important screen in the whole investigation. The Summary Metrics table shows min, 25th percentile, median, 75th percentile and max for every task metric. Read it as a comparison between the median and the max columns:

  • Duration: median 1 s, max 12 s.
  • Shuffle read: median 5.8 MiB, max 92.6 MiB and 11.2M records.
  • Spill: zero at every percentile except the max.

A note on reading spill, because it’s often misread: Spill (memory) is the size the spilled data had in memory, and Spill (disk) is the size of the same data once serialised and compressed on disk. So 384 MiB / 66 MiB is one spill, not two.

Also glance at the colours in the event timeline. Mostly green (computing) means CPU work, as here. Mostly orange (shuffle read) points to fetch or network problems. Lots of blue (scheduler delay) means a busy driver or too many tiny tasks. The reading map above lists them all.

One task got 16× the data of a typical task, ran 10× longer, and was the only one to spill. That’s data skew, and you can now say it with evidence. The task list confirms which task it is:

Spark UI task table with task index 18 highlighted: 12 seconds, 92.6 MiB shuffle read, 11.2 million records and 384 MiB spill

Compare it with the control run: same 24M rows, no hot key. The quantiles are close together, the timeline is a neat brick wall, and nothing spills.

Spark UI stage 10 of the no-skew control run: max task duration 3.3 seconds close to the median and shuffle read evenly spread
Join stage metricSkewed (today)No skew (control)
Task duration: median / max1.2 s / 12.2 s1.7 s / 3.3 s
Shuffle read: median / max5.8 MiB / 92.6 MiB~11 MiB / ~11 MiB
Spill384 MiB (one task)0
Stage time if all 32 tasks run at once≈ 12 s≈ 3 s

SQL plan: which operator suffered

Spark UI SQL plan of the skewed query: Exchange with max bytes read 92.6 MiB, Sort with 384 MiB spill from one task, and SortMergeJoin

Click the query in the SQL tab to see the physical plan with runtime metrics on every node. Each metric is shown as total (min, med, max). Here the Exchange read (5.6, 5.8, 92.6) MiB per partition, the Sort in front of the join spilled 384 MiB from one task, and the join itself is a SortMergeJoin, even though one side is a small customers table. That last detail is a clue we’ll use in the fixes.

Environment and Executors tabs: settings and health

Spark UI environment tab showing spark.sql.adaptive.enabled false and spark.sql.autoBroadcastJoinThreshold minus one

Before guessing, check what the run actually used. Here AQE is off and broadcast joins are disabled. In real incidents these come from old spark-defaults.conf files, cluster-level configs or a copy-pasted --conf, and a config that was harmless on even data becomes expensive when the data changes shape.

Spark UI executors summary with 11 minutes of task time, 4 seconds of GC time and zero failed tasks

The Executors tab rules out the other usual suspects: GC time is tiny (4 s of 11 min), no failed tasks, no dead executors. On a multi-node cluster, also look for one executor with far more task time or shuffle read than the others (skew again, or a bad node).

Where to find the Spark UI on EMR, EMR Serverless and Databricks

PlatformRunning jobFinished job
Amazon EMR on EC2Cluster → Applications → Spark UI (or YARN ResourceManager → ApplicationMaster)Persistent application UI / Spark History Server from the EMR console, backed by event logs
EMR ServerlessJob run → Spark UIJob run → Spark History Server
DatabricksCompute → your cluster → Spark UI, or the job run’s Spark UI linkSame links, while the logs are retained; serverless SQL uses the query profile instead
Self-managed / KubernetesDriver port 4040 (port-forward on K8s)Spark History Server with spark.eventLog.enabled=true and a shared log directory (S3 / HDFS)

The UI is the same everywhere; only the door is different. On EMR, also check the step’s stderr and YARN container logs if tasks failed, and whether Spot instances were reclaimed during the run: lost executors mean recomputed stages.

THE INTERVIEWER ASKS

Assume you can’t open the Spark UI. Now what?

What they’re testing: whether you know where the UI’s data comes from.

Strong answer: “The UI is just a view over the event log, so I’d read the same data another way. The History Server’s REST API gives the same numbers: /api/v1/applications/<id>/stages lists stages, and …/stages/<stage>/<attempt>/taskSummary gives the task quantiles. df.explain("formatted") shows the plan and join strategy before running. The driver and executor logs show spill and fetch failures. On EMR, CloudWatch and YARN metrics show resources. And a quick groupBy(key).count() on the input checks for skew directly.”

THE INTERVIEWER ASKS

You have the Spark UI open. What exactly do you look at first?

What they’re testing: a concrete, ordered method, not a tour of every tab.

Strong answer: “SQL tab to find the query that got slower and compare it with yesterday. Then its job, and the stage with the biggest time increase. In that stage, the summary metrics: if max ≈ median and everything is just bigger, it’s volume or partition count; if max is several times the median in both duration and shuffle read, it’s skew. Then the SQL plan to see which operator and which join strategy, and the Environment tab to see if a setting changed.”

Data skew in Spark: detect it and fix it

What is data skew in Spark, and why more executors don’t help

Bar chart of task durations modelled on the demo's join stage: in the skewed stage one red bar reaches 12 seconds while other tasks take about 1 second; in the no-skew stage all tasks are similar

Data skew in Spark means data is unevenly spread across partitions, so a few tasks do most of the work. Since a stage ends when its last task ends, the whole cluster waits for that one core. Doubling the cluster would halve the time of the 31 small tasks, which were never the problem, and leave the 12-second task exactly as slow.

Comic: 31 workers finish small 6 MiB boxes quickly, one worker struggles under a 92 MiB box for customer C-0000001, and Nikhil explains that hiring more workers won't help because everyone waits for the straggler

Skew mostly appears after a shuffle, because hash partitioning sends every row with the same key to the same partition. The usual victims:

  • Joins on a key with a few very frequent values (the most common case, and ours).
  • Aggregations (groupBy) on such a key, especially non-combinable ones like collect_list.
  • Window functions partitioned by such a key.
  • Writes partitioned by a column where one value dominates.
  • Input files of very different sizes (one 5 GB gzip file can’t be split and becomes one task).

How to detect data skew

In the UI, three signals together:

  1. Stage summary metrics: max duration ≫ median (a common rule of thumb is more than 3–5×).
  2. Max shuffle read ≫ median, in bytes and records.
  3. Spill and GC concentrated in the max column, and a long straggler bar in the event timeline.

Then confirm it in the data and find the culprit keys:

# 1. Which keys are hot? (sample first on huge tables)
(orders.groupBy("customer_id").count()
       .orderBy(F.desc("count")).limit(20).show())

# 2. How uneven are the partitions right now?
(orders.repartition(32, "customer_id")
       .groupBy(F.spark_partition_id().alias("pid")).count()
       .orderBy(F.desc("count")).show(5))

# 3. Watch for junk keys
orders.select(F.count(F.when(F.col("customer_id").isNull(), 1)).alias("null_keys")).show()

In the demo, the first query returns C-0000001 with about 10.8M of 24M rows. The next question is a business one, and a good candidate asks it: is this key real?

These checks also tell you which kind of skew you have, and that decides the fix:

  • Single-key skew: one key owns the big partition. More shuffle partitions can’t help, because the key still hashes to one partition. You need AQE splitting, broadcast, isolation or salting.
  • Hash-collision skew: several medium-sized keys happen to hash to the same partition. Here more partitions (or AQE’s sizing) does help, because they spread out.

Where skew comes from

CauseExampleBest first move
Junk or default keysNULL, '', 'unknown', -1, 0, test accountsFilter or handle separately; fix upstream
Real-world power lawA marketplace’s biggest seller, a viral product, a huge enterprise tenantBroadcast, AQE skew join, isolate hot keys, salting
Sudden new behaviourA new client onboarded, a campaign, a botSame as above, and alert on key share
Upstream bugA release writes the same ID into every rowData quality check; stop the pipeline rather than “fix” it in Spark
Partition column choiceWriting partitionBy("country") when 70% is one countryAdd a second key or salt when writing

Fix: turn on AQE skew join

Diagram of AQE skew join splitting one 92 MiB partition into four splits, each joined with a copy of the matching customers partition, with the rules for when a partition counts as skewed

With AQE on, after the shuffle Spark knows every partition’s real size. A partition is treated as skewed when it is larger than both skewedPartitionFactor × the median partition size and skewedPartitionThresholdInBytes. Spark then splits it into chunks of about advisoryPartitionSizeInBytes, and joins each chunk with a copy of the matching partition from the other side.

spark.sql.adaptive.enabled=true                                   # default since 3.2
spark.sql.adaptive.skewJoin.enabled=true                          # default true
spark.sql.adaptive.skewJoin.skewedPartitionFactor=5               # default 5
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes=256MB # default 256MB
spark.sql.adaptive.advisoryPartitionSizeInBytes=64MB              # default 64MB

The demo’s data is tiny, so we lowered the thresholds (factor 3, 16 MB) to make AQE react. In production the defaults are usually right; lower them only if the UI shows a skewed partition that AQE ignored. You can check whether it worked in the plan:

Spark UI plan with AQEShuffleRead showing one skewed partition split into four, and SortMergeJoin with skew=true
Spark UI stage 16 with AQE skew join: max task 7 seconds and max shuffle read 23.2 MiB across 35 tasks

Result: max task 12.2 s → 7.1 s, max shuffle read 92.6 → 23.2 MiB, with zero code changes. It isn’t perfect, because each split still spills a little, but it’s the cheapest fix available.

Where AQE skew join doesn’t help:

  • It only handles shuffle joins (sort-merge; shuffled hash join in newer versions). It doesn’t touch skewed groupBy, window functions or writes.
  • It can only split the side the join type allows, for example the left side of a left outer join. Check your Spark version’s docs for full outer joins.
  • It skips a split that would need an extra shuffle (unless spark.sql.adaptive.forceOptimizeSkewedJoin is set, Spark 3.3+), and it ignores skew below the thresholds.

Fix: broadcast the small side

Spark UI plan with BroadcastExchange of the 200,002-row customers table and a BroadcastHashJoin with no shuffle of orders

If one side of the join is small, don’t shuffle the big side at all. A broadcast hash join sends the small table once to every executor, and each partition of the big table joins locally. No shuffle of orders means no hot partition, so skew simply disappears.

from pyspark.sql import functions as F
report = orders.join(F.broadcast(customers), "customer_id")   # explicit hint

# or let Spark decide by size (default 10MB, estimated size)
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "64MB")

Result: query time 31 s → 15 s (13 s with AQE also on). This is also a classic answer to the “since yesterday” puzzle: if the small table grows past 10 MB (or its estimated size does), Spark silently switches from a broadcast join to a sort-merge join, and a job that never shuffled its big table suddenly does.

Why “small” is tricky: for file sources, Spark estimates size from the on-disk, compressed files, while the broadcast is built from decoded rows. Our customers table is about 40 MiB in memory; a 5 MB Parquet file can easily be 50 MB once decoded. Estimates after filters and joins can be even further off. AQE’s runtime switch uses real shuffle sizes, which is one more reason to keep it on.

Limits: the table must fit in the driver’s and every executor’s memory (hard limit 8 GB, but in practice keep it to tens or a few hundred MB); it’s built on the driver first; and in outer joins only the non-preserved side can be broadcast (the right side of a left join; never a full outer join). A too-large broadcast is itself a common cause of driver OOMs and broadcast timeouts.

Fix: salt the hot key

Step-by-step diagram of salting: add a salt (hash of order_id) to hot keys on the big side, explode the small side for hot keys, join on key plus salt, spread over N partitions, and how to choose N

When both sides are big, broadcast isn’t possible. Salting spreads the hot key over N partitions: add a salt value 0..N-1 to the hot key’s rows on the big side, copy the matching rows N times on the other side, and join on (key, salt). The result is identical; the work is spread out.

N   = 16
hot = ["C-0000001"]            # from the top-keys query, or a daily job that finds them

# deterministic salt from a unique column (safer than rand() if a stage is retried)
orders_s = orders.withColumn(
    "salt",
    F.when(F.col("customer_id").isin(hot), F.pmod(F.xxhash64("order_id"), F.lit(N)))
     .otherwise(F.lit(0)))

customers_s = customers.withColumn(
    "salt",
    F.explode(F.when(F.col("customer_id").isin(hot),
                     F.array(*[F.lit(i) for i in range(N)]))
               .otherwise(F.array(F.lit(0)))))

joined = orders_s.join(customers_s, ["customer_id", "salt"]).drop("salt")

Why a hash of order_id instead of rand()? A random value computed before a shuffle makes that stage non-deterministic: if a fetch fails and Spark has to recompute some map output, rows could land in different partitions than the first time. Spark handles this by re-running the whole stage, which is slow; a deterministic salt avoids the problem. (Our demo used rand() for brevity.)

This is selective salting: only hot keys are salted, so only their few matching rows are copied. Salting every key would multiply the whole small side by N and add shuffle for nothing, which is a common mistake in interview answers.

Spark UI stage 23 after salting: max task 5 seconds and max shuffle read 17.2 MiB with no spill

Result: max task 12.2 s → 5.1 s, max shuffle read 92.6 → 17.2 MiB, spill 384 MiB → 0. Choosing N: roughly the hot key’s data size divided by a healthy task size (128–256 MB). Too small and the hot key is still a straggler; too big and you copy the small side more than needed.

Fix: two-stage aggregation for a skewed groupBy

Diagram of two-stage aggregation: aggregate by key and salt first, then by key, with sample PySpark code

Good news first: for sum, count, min, max and avg, Spark already does a partial aggregation before the shuffle (you’ll see two HashAggregate nodes in the plan), so a hot key sends only one partial row per task. Aggregation skew hurts when the aggregate can’t be pre-combined cheaply, such as collect_list, exact percentiles or heavy UDAFs. Then aggregate twice: first by (key, salt), then by key. As with joins, prefer a deterministic salt (a hash of a unique column) over rand(). Also watch several countDistincts in one query: Spark expands each row once per distinct aggregate (an Expand node in the plan), which can multiply the shuffle several times over.

Fix: isolate the hot keys and union

When you know the few hot keys, process them on a separate path and union the results:

is_hot = F.col("customer_id").isin(hot)

normal = orders.filter(~is_hot).join(customers, "customer_id")              # regular shuffle join
hot_rows = orders.filter(is_hot).join(
    F.broadcast(customers.filter(F.col("customer_id").isin(hot))), "customer_id")  # tiny broadcast

joined = normal.unionByName(hot_rows)

This keeps the big path untouched and gives the hot rows a broadcast join against a handful of customer rows. It’s easy to explain and easy to debug, which matters at 2 a.m.

Fix: drop or route junk keys

If the hot key is NULL, an empty string or a placeholder like 'unknown', the best fix happens before the join.

  • NULL keys in an inner join: Spark’s optimizer already adds an isnotnull(key) filter for inner equi-joins, so NULLs never reach the shuffle. If you see NULL skew, look for an outer join.
  • NULL keys in an outer join: the rows must stay, and they all hash to one partition. Replace the NULL with a random value that can never match, so the rows spread out and still come back unmatched:
    orders.withColumn("join_key",
        F.coalesce(F.col("customer_id"),
                   F.concat(F.lit("__null_"), F.pmod(F.xxhash64("order_id"), F.lit(64)).cast("string"))))
    # join on join_key = customers.customer_id: the "__null_…" values never match,
    # so these rows stay unmatched (as before) but are spread over 64 partitions
  • Placeholders ('', 'unknown', -1): Spark treats these as real values, so filter them or route them separately.
  • Upstream bugs: if the “hot key” exists because of a bad release, the right answer is a data quality check that stops the pipeline, not a clever Spark trick.

Skew in window functions and writes

  • Window functions: Window.partitionBy("customer_id") puts every row of the hot customer in one task. Salting only works if the calculation can be split and recombined (a running sum per day, then a sum of sums). For row_number() across the whole key, isolate the hot key or change the logic, for example compute per day first.
  • Writes: partitionBy("date") with one huge date means one task writes a giant file. Use repartition("date", salt) or spark.sql.files.maxRecordsPerFile, or the REBALANCE hint, which AQE can split when it is skewed.
  • Hints on Databricks: Databricks also accepts a SKEW hint, for example /*+ SKEW('orders', 'customer_id') */. It’s Databricks-specific, and with AQE it’s rarely needed; open-source Spark ignores it.

Choosing a fix

Decision flow for choosing a skew fix: junk keys, broadcast, AQE skew join, isolate hot keys, salting, and window or write skew
FixEffortWorks forWatch out for
Filter / route junk keysLowNULL and placeholder keysDon’t drop rows an outer join must keep
Broadcast joinLowOne side smallDriver/executor memory, outer-join side rules
AQE skew joinConfig onlyShuffle joinsThresholds; not for groupBy, windows or writes
Isolate hot keys + unionMediumA few known hot keysKeeping the hot-key list current
SaltingMediumBig-big joins, unknown hot keysChoosing N, copying the small side
Two-stage aggregationMediumNon-combinable aggregatesOnly for aggregates that can be merged

THE INTERVIEWER ASKS

When does salting make things worse?

What they’re testing: whether you know the cost of the trick, not just the trick.

Strong answer: “When you salt every key instead of the hot ones, the small side is copied N times and the shuffle grows for no benefit. When N is far larger than needed, same problem. And when broadcast or AQE would have solved it with a config change, salting adds code that someone has to maintain. I use it for big-big joins where the simpler fixes don’t apply, and I salt selectively.”

THE INTERVIEWER ASKS

AQE is on by default in Spark 3. Why would a job still be skewed?

What they’re testing: whether you know AQE’s limits.

Strong answer: “Someone may have turned it off, which is what the Environment tab showed here. Even when it’s on, the skewed partition might be under the 256 MB threshold, the skew might be in an aggregation, a window or a write rather than a join, the join type might not allow splitting that side, or the split might need an extra shuffle. So I verify in the plan: AQEShuffleRead should show skewed partitions and the join should say skew=true.”

Other causes of a slow Spark job

Skew is the classic answer, but a strong candidate shows they’d rule out the others. Same method: symptom → where you see it → fix, without spending more.

SuspectWhat you see in the UIFix at the same cost
Broadcast join became sort-mergePlan shows SortMergeJoin + new Exchange where yesterday had BroadcastHashJoinBroadcast hint, sensible threshold, AQE (can switch at runtime)
Too few shuffle partitions for the new volumeMedian task is big and slow, spill in most tasksRaise spark.sql.shuffle.partitions or let AQE size them
Too many tiny partitions / filesThousands of millisecond tasks, high scheduler delay, slow file listingCompact files, AQE coalescing, maxPartitionBytes
Lost partition pruning / filter pushdownInput size jumped; no PartitionFilters in the scan nodeFilter directly on the partition column; don’t wrap it in functions
Join explosion from duplicatesJoin output rows ≫ input rowsDeduplicate; add a uniqueness check upstream
Python UDFBatchEvalPython / ArrowEvalPython nodes taking the timeBuilt-in functions, or vectorised pandas UDFs
Memory pressure / GCGC time above ~10% of task time in the Executors tabSmaller partitions, fewer cached objects, drop unused columns
RecomputationThe same stages run twice for one DataFrame used twicepersist() what’s reused, unpersist() after
A slow or sick nodeOne executor slow for all its tasks, whatever their sizespark.speculation (only with idempotent output), exclude the node
Fewer resources than usualFewer executors in the timeline, pending tasks, lost Spot nodesQueue / scheduling, run window, retries: no hardware change needed
Executors that don’t fill the clusterYARN / Executors tab: fewer cores in use than the nodes have (1-core executors, memory rounding that fits 2 executors where 3 would fit)Right-size spark.executor.cores (often 4–5) and memory to the instance type: same nodes, more slots
Stage retries / fetch failuresRetried stages, FetchFailed errors, lost executors (Spot reclaims)External shuffle service, Spot for task nodes only, fix the failing node
Non-splittable inputOne input task reading a huge .gz / .zip fileColumnar formats (Parquet/ORC split by row group with any codec), bzip2 for text, or smaller files upstream
Many countDistinctsExpand node multiplying rows before the shuffleapprox_count_distinct, or compute distinct counts separately
Driver bottleneckLong gaps between jobs, collect() / toPandas() on big dataKeep work distributed; write results out instead of collecting

For the tuning parameters behind these fixes (executor sizing, serialisation, caching, file formats), our Spark performance tuning guide goes knob by knob.

How to fix a slow Spark job without extra cost

Nikhil closes the loop with numbers from the demo. Same laptop, same data, nothing added:

VersionQuery timeJoin stage: max taskMax shuffle readSpillChange
Today (skewed)31 s12.2 s92.6 MiB384 MiB–
Fix A: AQE skew join27 s7.1 s23.2 MiB128 MiB max per taskConfig only
Fix B: selective salting26 s5.1 s17.2 MiB0Code change
Fix C: broadcast customers15 sno shuffle join–0One-line hint
AQE on + broadcast13 sno shuffle join–0Config + hint

And the order he’d apply them in production:

  1. Restore sane configs: AQE on, broadcast threshold not -1. Zero code, zero cost.
  2. Broadcast the dimension table if it’s small; check its size trend so it doesn’t silently cross the limit later.
  3. For big-big joins, check that AQE splits the skewed partitions; tune its thresholds only if the UI shows it didn’t.
  4. If skew persists, isolate the known hot keys or salt selectively.
  5. Fix the data: filter junk keys early and raise the upstream bug if the hot key shouldn’t exist.
  6. Verify performance with the same metrics: stage max vs median, spill, total runtime vs yesterday.
  7. Verify correctness: salting and hot-key splitting are code changes. Compare row counts and key totals (e.g. sum of gmv per region) between the old and new versions on the same input before switching.

💡 Interview tip: Say the trade-off out loud: “The broadcast fix is the fastest, but it depends on customers staying small. I’d add a size check so that if it grows past a few hundred MB we know before the job slows down, not after.”

Preventing Spark performance regressions

  • Runtime alerts against a baseline: alert when a run exceeds, say, 1.5× the median of the last 14 runs, not a fixed number that’s wrong after every growth spurt.
  • Input and key-distribution checks: row counts per day, null-key rate, and the share of the top key. “One customer owns 45% of today’s orders” should be a data quality alert before it’s a performance incident.
  • Keep the evidence: event logs on durable storage (S3) and a History Server, so yesterday’s good run is always there to compare against.
  • Config hygiene: job configs in version control and code review; no cluster-wide -1s left over from an old experiment.
  • Stage-level metrics: export task duration and shuffle-read quantiles per stage (Spark listeners, the REST API or your platform’s metrics) and alert on max ÷ median.
  • Capacity trends: data growth is predictable; plot it next to runtime and SLA so “data has grown” is never a surprise.

Bonus question: what if memory were unlimited?

Near the end of the practice round, the interviewer tries to knock the whole answer over with one twist:

“Forget the budget. Say I give you a cluster with unlimited memory. Would you still split the skewed partition, or just let it run?”

It’s a trap in both directions. “No, memory solves it” shows you think skew is a memory problem. “Yes, always split” without a reason sounds memorised. Here’s how Nikhil answers, one layer at a time.

Comic: the interviewer offers unlimited RAM; the single worker stops spilling but still carries 11.2 million rows alone; splitting the box across four workers makes it about four times faster
Illustrative bars: today the hot task computes for 10 seconds and spills for 2; with unlimited memory the spill disappears but the task still takes about 10 seconds; splitting into 4 tasks brings it to about 2.6 seconds; unlimited memory plus a broadcast join removes the shuffle and the hot task

The timings in this image are illustrative, derived from the demo’s measured 12.2 s hot task. The real AQE run earlier reached 7.1 s rather than the ideal ≈ 12 ÷ 4, because each split still spilled and all tasks shared a 2-core laptop. With memory to spare and free cores, splits get close to the ideal.

Short answer: yes, I would still split

“Yes, I’d still split, because memory isn’t the bottleneck; parallelism is. Unlimited memory removes the spill and the risk of an out-of-memory error, but the hot partition is still processed by one task, and one task runs on one core. The stage still waits for that task. Memory makes the slow task a bit less slow; splitting makes it several fast tasks.”

Why memory alone doesn’t fix skew

Go back to the formula: stage time ≈ max(slowest task, total work ÷ slots). Memory doesn’t appear in it. It only changes how long the slowest task takes, and only by the part of that time that was caused by memory:

What the hot task spends time onDoes unlimited memory remove it?
Spilling to disk and merging the spilled runsYes: everything fits in memory
Out-of-memory failures and task retriesYes
Fetching its 92.6 MiB of shuffle blocksNo: same bytes over the network
Sorting / hashing 11.2M rows for the joinNo: CPU work on one core
Joining and pre-aggregating 11.2M rowsNo: CPU work on one core

In the demo, the hot task handled about 27× the rows of a typical task (11.2M vs about 420k). Take the spill away and it is still doing 27× the work on one core while 31 other cores sit idle. The skew is the same; it just stops spilling.

Two more details an expert would add. First, with memory to spare you can let Spark use a shuffled hash join instead of a sort-merge join (preferSortMergeJoin=false, or AQE’s maxShuffledHashJoinLocalMapThreshold, off by default), which skips the sort. That makes the hot task cheaper, but it’s still one task. Second, “unlimited memory” is never quite free in a JVM: huge heaps full of objects mean longer garbage-collection pauses. Spark’s Tungsten binary format keeps most join and sort data off the object heap, which helps, but it doesn’t make the hot task parallel.

What unlimited memory does change: which fix I choose

This is the part that separates a good answer from a great one. Memory doesn’t remove the need to spread the work, but it changes the cheapest way to do it:

  • Broadcast becomes the first choice, even for big tables. With memory to spare on the driver and every executor, I can broadcast a much larger “small” side. Then there’s no shuffle of the big table at all, so the hot key’s rows stay spread across all the input partitions. It isn’t a split; the skew never forms. The limits still apply: Spark’s 8 GB hard limit on a broadcast table, the time to collect it on the driver, and the network cost of sending it to every executor (size × number of executors).
  • Fewer, larger partitions become OK for the non-skewed part. Without spill risk, I can raise advisoryPartitionSizeInBytes and let AQE coalesce into fewer, bigger tasks: less scheduling overhead and fewer shuffle files. The limit is parallelism: keep at least as many tasks as task slots, or cores sit idle.
  • Caching becomes cheap. If the same input feeds several steps, persist() in memory avoids re-reading it.
  • The skewed task itself still gets split (AQE skew join or selective salting) whenever the join has to shuffle.

When I would not split

“Always split” isn’t the right answer either. Splitting has a cost: the other side’s matching partition is copied once per split, and salting adds code to maintain. I’d leave it alone when:

  • The cluster has few slots and the total work ÷ slots term already dominates, as on our 4-slot laptop, where skewed and even runs took the same time. Splitting won’t shorten the stage.
  • The skewed stage isn’t on the critical path, for example a small side branch that finishes while a bigger stage is still running.
  • The hot partition is only modestly bigger (say 2× the median, below AQE’s thresholds). The gain doesn’t pay for the extra copies and tasks.
  • A broadcast removes the shuffle entirely. Then there’s nothing to split.

THE INTERVIEWER ASKS

So, final answer: with unlimited memory, split or not?

What they’re testing: whether you understand the difference between memory and parallelism, and can adapt your plan instead of reciting it.

Strong answer: “Memory and parallelism solve different problems. Unlimited memory fixes spill and OOMs, but not the fact that one partition is one task on one core, so the stage still waits for it. My first move would change, though: with that much memory I’d broadcast the smaller side and avoid the shuffle, so the skew never forms. If both sides are too big to broadcast even then, I’d still split the hot key with AQE or selective salting. I’d skip splitting only when there are fewer slots than tasks anyway, or the gain is too small to justify copying the other side.”

Bonus question: how can AI help you fix this?

The last question of the practice round is a very current one:

“Everyone uses AI assistants now. How would an LLM help you resolve this issue?”

Both easy answers are weak. “I’d paste the error into ChatGPT” sounds like you’d skip the thinking. “I wouldn’t trust AI” sounds out of date. The interviewer wants to know whether you can use AI to go faster without giving up the evidence. Nikhil’s short answer:

“I’d use it as a fast assistant inside the same debugging loop, not as the one who decides. I give it evidence: stage metrics, the physical plan, the config diff. It gives me ranked hypotheses, explanations and draft code. Then I verify every claim in the Spark UI and test every change, the same as I would with a colleague’s suggestion.”

Comic: Nikhil hands an evidence pack of metrics, plan and config diff to an AI assistant, the assistant ranks skew in stage 6 as the top cause with numbers, and Nikhil confirms it in the Spark UI before testing the fix
The six-step debugging loop with what AI can do at each step, what you verify yourself, and five guardrails: send metrics not customer rows, check config keys, reject generic advice, summarise logs first, read-only agents

Where AI speeds up each step

  • What changed: summarising the git history of the job, and diffing yesterday’s and today’s configs and physical plans. A changed join strategy is easy to miss in two 300-line plans and easy for a model to point out.
  • Finding the slow stage: the model doesn’t need to “read” the UI. It writes the script that pulls the numbers from the Spark History Server REST API, and the numbers come from Spark, not from the model.
  • Reading the evidence: explaining a physical plan node by node, which is great for less experienced engineers on call at 2 a.m.
  • Naming the cause: producing a ranked list of hypotheses, each with the metric that supports it and the check that would rule it out.
  • Fixing: drafting the selective-salting or hot-key isolation code, plus a test that compares old and new output on the same input.
  • Preventing: writing the runtime alerts, the key-share data quality check and the runbook entry.

The trick is to give the model an evidence pack, not a vague question. A few lines of Python build it from the Spark History Server REST API:

import json, requests

API = "http://<history-server>:18080/api/v1/applications/<app-id>"   # YARN cluster mode: append /<attempt-id>
pack = []
for s in requests.get(f"{API}/stages", params={"status": "complete"}).json():
    q = requests.get(f"{API}/stages/{s['stageId']}/{s['attemptId']}/taskSummary",
                     params={"quantiles": "0.5,1.0"}).json()          # median, max
    pack.append({"stage": s["stageId"], "name": s["name"][:60], "tasks": s["numTasks"],
                 "run_ms_median_max": q["executorRunTime"],
                 "shuffle_read_bytes_median_max": q["shuffleReadMetrics"]["readBytes"],
                 "spill_bytes_median_max": q["memoryBytesSpilled"]})
print(json.dumps(pack, indent=1))    # add: df.explain("formatted") + the config diff

Then ask a question that forces an evidence-based answer:

“A daily Spark job went from 10 to 45 minutes; data grew about 3×. Below are per-stage task metrics for yesterday and today, today’s physical plan, and the config diff. Rank the three most likely causes. For each, quote the exact metric that supports it and the check that would confirm or rule it out. Don’t suggest adding hardware. Only use configuration keys that exist in the official Spark documentation.”

With our demo’s numbers, the pack makes the answer hard to miss: one stage with a 12.2 s max vs a 1.2 s median, a 92.6 MiB partition against a 5.8 MiB median, spill in a single task, and a SortMergeJoin against a small table. A good assistant should reach the conclusion we reached by hand, only faster. That’s the value: speed, not a different answer. And if it reaches a different answer, the metrics it quotes tell you immediately whether to take it seriously.

Tools you can mention

  • AWS Apache Spark Troubleshooting Agent (Amazon EMR on EC2, EMR Serverless, EMR on EKS and AWS Glue): works through an MCP-compatible AI assistant in your IDE. It analyses Spark History Server logs, configurations and error traces, and returns root-cause analysis and code or configuration recommendations. It gives recommendations only; you apply and validate them.
  • Databricks Assistant: helps explain and fix code and errors inside Databricks notebooks. Pair it with the Spark UI and query profile for the performance evidence.
  • Any approved general-purpose assistant: given the evidence pack above, it can do most of the reasoning steps, as long as your company allows the data to be shared with it.

Where AI falls short, and how to stay safe

RiskWhat it looks likeGuardrail
No runtime contextGeneric advice: “increase executor memory”, “add partitions”Always attach metrics and the plan; ask it to cite them
Made-up detailsConfig keys or API fields that look plausible but don’t existCheck every key in the Spark docs; test in a lower environment
Data exposureSample rows with customer IDs or PII pasted into a promptShare metrics, plans and schemas, not rows; use the company-approved tool
Too much inputMulti-GB event logs don’t fit in a promptSummarise with code first (as above), then send the summary
Unreviewed changesAn agent editing job configs or cluster settings on its ownRead-only access for agents; changes go through code review
Wrong but confidentA plausible diagnosis that the metrics don’t supportVerify in the Spark UI before and after, like any colleague’s suggestion

THE INTERVIEWER ASKS

So would you let an AI agent fix production Spark jobs automatically?

What they’re testing: judgment about automation, risk and accountability.

Strong answer: “I’d let it do the reading automatically: when a job breaches its runtime baseline, an agent with read-only access pulls the stage metrics, the plan and the config diff, and posts a first diagnosis to the on-call channel. That cuts the time to the first good hypothesis a lot. Changes stay with a human: the fix goes through a pull request with an output-comparison test, because a wrong salting or filter change can silently corrupt a report, which is worse than a slow one.”

Summary: Spark performance interview cheat sheet

The two-minute answer

“Since the cost is fixed, I won’t reach for more hardware. I’ll find what changed and where the time goes; 4.5× slower on 3× the data is non-linear, so I suspect skew, spill or a changed join plan. First, what changed: volume, key distribution, code, configs, cluster. Then the Spark UI: the SQL tab to find the query that slowed down, the job’s DAG to find the slowest stage, and that stage’s task quantiles. If max ≈ median, it’s volume, partition count or resources. If max is many times the median in duration and shuffle read, with spill only in the max task, it’s data skew, and the task list and a top-keys query tell me which key.

To fix it without spending more: restore AQE and let it split skewed partitions; broadcast the small table so the big side isn’t shuffled at all; and if both sides are big, isolate or salt the hot keys. If the hot key is junk, filter it and fix upstream. Then I verify the same metrics and add alerts on runtime, input size and top-key share so we hear about it before the business does.”

Cheat sheet

ConceptOne-liner
Opening lineConstraint + method + hypothesis, in three sentences
Job / stage / taskAction → job; shuffle → new stage; partition → task on one slot
Stage time≈ max(slowest task, total task time ÷ slots)
Why one task mattersA stage ends when its slowest task ends
Skew signatureMax ≫ median in duration and shuffle read; spill only at max
Volume signatureAll quantiles grow together
AQERe-plans after each shuffle: coalesce, switch to broadcast (or shuffled hash join, if enabled), split skew
Skew join rulePartition > factor (5) × median AND > 256 MB → split to ~64 MB
BroadcastNo shuffle of the big side; default 10 MB threshold, 8 GB hard limit
SaltingSalt hot keys only (hash of a unique column), explode the small side, join on key + salt
Partial aggregationsum/count already pre-combine; salt only non-combinable aggregates
SpillExecution memory overflow to disk: a symptom, not a failure. Memory/disk columns are the same data
Unlimited memory?Removes spill/OOM, not the single slow task; still split, or broadcast so the skew never forms
AI assistantsEvidence in, ranked hypotheses and drafts out; you verify and approve
NULL keysInner joins drop them automatically; outer joins need them spread

Common answer vs strong answer

Common answerStrong answer
Opens with “I’d increase memory.”Opens with the constraint, a method and a hypothesis to check.
“I’d add executors / memory.”“One task holds 16× the data; more cores won’t help it. Let me split or avoid that shuffle.”
“I’d check the logs.”“SQL tab → job → slowest stage → task quantiles → plan → environment.”
“The data grew.”“Data grew 3×, time grew 4.5×: something non-linear. Quantiles say skew.”
“Use salting.”“Check for junk keys, then broadcast, then AQE; salt selectively only for big-big joins.”
“Increase shuffle partitions.”“That helps volume, not skew: the hot key still hashes to one partition.”
“I’d ask ChatGPT.” / “I don’t use AI.”“I give the model metrics, plan and config diff, it ranks hypotheses, I verify in the UI.”
Stops at the fix.“Verified with the same metrics, and added alerts on runtime, input size and top-key share.”

Spark performance interview bingo

Spark performance interview bingo card with twenty common follow-up questions, all answered in this post

Every square on that card is answered somewhere above. If you can talk through each one with a screenshot in your head, this question stops being scary and becomes one of the best chances in the loop to show how you actually work.

Frequently asked questions

Why is my Spark job suddenly slow?

Usually because something changed: more data, a different key distribution (data skew), a code or config change, a small table that grew past the broadcast threshold, or fewer resources. Compare yesterday’s and today’s runs in the Spark UI, find the stage that got slower, and compare its median and max task metrics.

What is data skew in Spark?

Data skew is an uneven spread of data across partitions, usually because a few join or group-by keys are far more frequent than others. After a shuffle, all rows with the same key land in one partition, so one task does most of the work while the other cores wait.

How do I find data skew in the Spark UI?

Open the slow stage in the Stages tab and read the Summary Metrics. If the max task duration and max shuffle read are several times the median, and spill appears only in the max column, you have skew. The task list shows which partition it is; a groupBy(key).count() shows which key.

How do you fix data skew in Spark?

Cheapest first: make sure AQE and its skew join are on, broadcast the smaller side of the join, filter or spread junk keys such as NULLs, isolate known hot keys on a separate path, and use selective salting for joins where both sides are big.

Does AQE fix data skew automatically?

Partly. AQE’s skew join splits oversized partitions in sort-merge joins when a partition is larger than 5 times the median and 256 MB (defaults). It doesn’t fix skewed aggregations, window functions or writes, skew below the thresholds, or joins where it’s turned off.

What is salting in Spark?

Salting adds a salt value (0 to N-1) to rows with a hot key on the big side of a join, and copies the matching rows N times on the other side, so the hot key is spread over N partitions. Salt only the hot keys and derive the salt from a unique column rather than rand().

Will adding more executors or memory fix a skewed Spark job?

Not by itself. More memory removes spill, and more executors speed up the other tasks, but a skewed partition is still processed by one task on one core. Stage time is roughly max(slowest task, total work ÷ task slots).

What do Spill (memory) and Spill (disk) mean in the Spark UI?

Both describe the same spilled data: Spill (memory) is its size in memory before spilling, and Spill (disk) is its serialized, compressed size on disk. Spill in only one task usually points to data skew.

Further reading on DataForGeeks: