The Spark Pipeline Debugging & Performance Engineering Handbook
From "why is my job stuck?" to root cause, optimization, cost control, and prevention. This is not ten tuning tips. It is the procedure a senior engineer actually follows between the moment the alert fires and the moment the pipeline is boring again.
- Baseline
- Apache Spark 4.x3.x differences called out inline
- Diagrams
- 31every one teaches a mechanism
- Case files
- 20symptom → evidence → root cause
- Interview drills
- 3921 inline · 18 dedicated scenarios
Every recommendation that depends on your environment is labelled. Three labels appear throughout:
Apache Spark default — the documented default of open-source Apache Spark. Verify it in the Environment tab of your own run; it is the only place that tells you what your job actually used.
Platform-dependent — Databricks, EMR, Glue, Dataproc, Synapse/Fabric and every internal platform ship their own defaults, their own shuffle implementations, and sometimes their own forks of the optimizer. A "Spark default" quoted from a vendor blog is not a Spark default.
Workload-dependent heuristic — a starting number, not a law. Wherever a number appears, this handbook explains which metric should have produced it and when it stops being true.
Version-dependent — behaviour that changed between Spark releases, or that exists only in some builds.
Thirty parts, plus three master artifacts
- A Spark job is a crime scene
- The five-minute production triage
- Know where to look
- Reading Spark logs like an engineer
- Memory engineering
- Garbage collection
- Data skew
- Shuffle engineering
- Join engineering
- Driver bottlenecks
- CPU bottlenecks
- Storage and file engineering
- Partitioning, three different things
- Storage-partition-aware joins
- Caching: the optimization that backfires
- Dynamic allocation and cluster sizing
- Stragglers and speculative execution
- Network and shuffle failures
- Local disk bottlenecks
- SQL anti-patterns
- Data problems that look like Spark problems
- Reading performance regressions
- Cost optimization
- The scientific tuning method
- The Spark troubleshooting matrix
- Twenty production case files
- Interview mode — 18 dedicated scenarios
- Configuration reference, with context
- Observability before the incident
- Prevention
✦ The three master artifacts — one-page decision tree · Spark UI cheat sheet · production runbook
✦ References & version notes — primary sources, and what this handbook assumes about your version
A Spark job is a crime scene.
You do not walk into a crime scene and start rearranging the furniture. You walk in, you look, and you establish where the event happened before you theorise about why. A Spark pipeline is exactly this. It is a stack of nested abstractions, and a symptom at the top — "the DAG ran for three hours" — is produced somewhere very specific further down. Your entire job in the first twenty minutes is to find that somewhere.
The chain below is the map. Every incident has a primary root-cause layer, and the levels are ordered: a cause low in the chain surfaces as a symptom higher up, rarely the other way around. Your job is to find the layer where the cost actually originates.
Real incidents often cascade across several layers, and that is exactly why the ordering matters. A skewed key (data) produces an oversized partition (tasks), which spills (memory), which saturates local disk (disk), which lengthens GC (executors), which misses a heartbeat (network), which loses an executor, which triggers a FetchFailedException (shuffle). You will meet that incident at the bottom of the chain and be tempted to fix it there. The discipline is to walk back up to the layer that started it — every fix applied downstream of the origin buys time and nothing else.
Never start by changing configurations. Start by finding where the time or the failure occurs.
Configuration changes are cheap to make and expensive to reason about. Every one you apply before you have located the bottleneck adds a variable to an experiment you have not designed yet. Three configs later you have a job that is 8% faster for reasons nobody can explain, and a runbook that says --conf spark.executor.memory=24g # don't remove, it breaks.
The loop that works, and the loop that doesn't
The folklore loop is not stupid — it is rational under time pressure. At 3 a.m., raising memory and retrying feels like action, and occasionally it works, which is exactly what makes it durable. The problem is that it never produces knowledge. Six months later the same pipeline fails the same way and the runbook says "try more memory," because that is all anyone ever learned.
The disciplined loop is slower for one incident and dramatically faster across ten. It also produces the artefact that matters most: a sentence a colleague can read. "The job spends 71% of its wall clock in stage 14; stage 14's max task reads 41 GB of shuffle against a 380 MB median; the key is merchant_id and 34% of rows carry the sentinel -1." That sentence contains the fix. Nothing about executor memory does.
Three sentences to keep on a sticky note
1. Spark UI tells you WHERE the pain is. Logs often tell you WHY. They are different instruments and neither substitutes for the other. The UI is a profiler; the log is a stack trace.
2. Before touching executor memory, answer one question: are all tasks hurting, or only two? "All" and "two" have almost no fixes in common. Getting this wrong wastes the entire incident.
3. Configuration is medicine. Diagnose before prescribing. Every config in Part XXVIII has a condition under which it helps and a condition under which it merely hides the problem until the data grows 20% more.
"Walk me through what you do when a production Spark job that normally takes 40 minutes has been running for three hours."
- Weak
- "I'd check the logs and probably bump the cluster size."
- Good
- "I'd open the Spark UI, find the stage that's taking the time, and look at whether it's skew."
- Senior
- "First I'd separate pipeline time from Spark time in the scheduler — a three-hour pipeline may be a 40-minute Spark job behind a two-hour upstream wait. If it really is Spark, I go to the Jobs tab for the active job, then the active stage, then the task table sorted by duration, and I compare the max task against the median on duration, shuffle read and records. Those three numbers tell me in about ninety seconds whether I'm looking at skew, uniform under-partitioning, a straggler node, or an executor that keeps dying. Only then do I look at logs, and only then do I consider a config. And before I change anything I write down the current runtime, input bytes, shuffle bytes and executor-hours, because otherwise I can't tell later whether I helped."
The first five minutes.
Triage is not diagnosis. Triage is the cheap, fast set of questions that routes you into the right diagnostic branch so you don't spend forty minutes reading GC logs for a job that never got an executor. Five minutes, three questions, in this order.
A surprising share of "Spark incidents" are not Spark incidents. If there is no application ID, no driver log, and no entry in the History Server, then nothing you know about shuffle partitions is relevant. The failure is in orchestration, provisioning, packaging or identity, and the evidence lives in the scheduler and cluster-manager logs — not in the Spark UI, which does not exist yet.
The tell: a "failed" pipeline whose total duration is suspiciously round and short — 60 s, 300 s, 900 s. Round numbers are timeouts, and timeouts at submit time are almost always provisioning or authentication.
Branch A — RUNNING but slow
You have an active application. Open the Jobs tab, find the active job, open its active stage. Now ask these in order. The first four are worth more than the other nine combined, because they separate the two fundamentally different worlds: everything is slow versus one thing is slow.
The thirteen questions, in priority order
- Is one stage dominating? If 80% of wall clock is in one stage, that stage is the incident. Everything else is noise.
- Are most tasks finished except a few? The "stuck at 99%" shape. Skew or straggler — Parts VII and XVII separate them.
- Are all tasks uniformly slow? Under-partitioning, an expensive per-row operation, or an under-provisioned cluster. Max ≈ median is the signature.
- Are executors fully utilised? Compare running tasks against total slots (executors × cores). Half-idle slots with pending tasks means a scheduling or locality problem, not a compute problem.
- Are tasks pending? Pending with idle capacity is a placement problem; pending with no capacity is a sizing problem.
- Is CPU high or low? High CPU with low I/O means computation — UDFs, regex, JSON, compression. Low CPU with the job still crawling means you are waiting on something: disk, network, or a lock.
- Is memory pressure high? Look at spill first, not at the memory config — spill is the most actionable sign that execution-memory pressure is costing you time, because it appears only when something actually had to be written out. Read it alongside Peak Execution Memory in the stage metrics and the peak memory columns in the Executors tab: spill tells you pressure became expensive, the peaks tell you how close you are to that happening.
- Is GC high? GC time as a fraction of executor run time. Above roughly 10–15% you have an allocation problem workload-dependent — see Part VI for why that band and when it's wrong.
- Is shuffle read/write huge? Compare shuffle bytes to input bytes. Shuffle ≫ input usually means a join or aggregation is moving far more than it needs to.
- Is spill high? Any disk spill in the gigabytes-per-task range means partitions do not fit in execution memory.
- Is input unexpectedly huge? The most under-checked question in this list. Part XXI exists because of it.
- Is output unexpectedly huge? A join that fanned out. Output rows ≫ input rows is a cardinality bug, not a performance bug.
- Are executors disappearing, or is the job retrying tasks? Retry storms make a job look "slow" when it is actually failing repeatedly and recovering. Completely different fix.
Branch B — FAILED
A failed Spark job gives you one enormous advantage over a slow one: it left a stack trace. Your first question is not "what does this exception mean" but whose exception is it — the driver's or an executor's. That single split halves the search space immediately, because driver failures and executor failures share almost no root causes.
- Driver failure or executor failure? Driver = the coordinator was overwhelmed or the code threw. Executor = distributed work broke.
- OOM? And if so, which of the four kinds — see Part V. "OutOfMemoryError" and "container killed" are not the same failure.
- FetchFailedException? A shuffle block could not be read. Part XVIII.
- Container / pod killed? The cluster manager killed you for exceeding a memory limit, or reclaimed the node.
- Heartbeat timeout? The executor stopped answering. Usually a symptom, rarely a cause.
- Disk failure or disk full? Shuffle and spill both need local scratch space. Part XIX.
- Serialization failure?
NotSerializableException, Kryo buffer overflow, or a task too large to ship. - Schema error? Column missing, type changed, nested field renamed. Part XXI.
- Permissions? Token expiry mid-run looks like a random failure at minute 55 of a 60-minute job.
- Missing file? Classic when an upstream job rewrites a partition while you are reading it.
- Network / object-store error? 5xx, throttling, connection reset. Often retryable, sometimes a sign you are hammering one prefix.
- Python worker crash? Segfault in a native library, or the worker was killed for memory.
- Bad SQL? Analysis exceptions fail fast and cheap. Be grateful.
- Result too large?
spark.driver.maxResultSizeexceeded — someone calledcollect(). Part X.
Branch C — COMPLETED but slow
This is the branch where discipline pays and improvisation fails, because there is no dramatic evidence. Nothing crashed. The job produced correct output. It just took 126 minutes instead of 42. There is exactly one technique that works here: compare against a baseline, metric by metric, and find the one that moved disproportionately.
| Dimension | What a normal change looks like | What a red flag looks like |
|---|---|---|
| Runtime | ±15% day to day | 3× with input up only 5% |
| Input volume | Grows with the business | Doubles overnight — check for a reprocessed source |
| Output volume | Tracks input | Grows faster than input — a join fanned out |
| Executor-hours | Tracks runtime × cluster size | Rises while runtime falls — you bought speed with money |
| Shuffle bytes | Tracks input | 4× while input is flat — plan changed, or a broadcast stopped happening |
| CPU time | Tracks records processed | Flat while runtime triples — you are waiting, not computing |
| GC time | Small, stable fraction | Rising fraction — cache pressure or object churn |
| Spill | Ideally zero | Appears where it never used to — partitions outgrew execution memory |
| File count | Stable per partition | 10× more files — an upstream writer changed |
| Task count | Stable | Explodes — partition sizing or file layout changed |
| Query plan | Identical fingerprint | A join strategy flipped — stats went stale |
| Cluster size | Whatever you asked for | Smaller than requested — quota or spot reclamation |
| Cost | Tracks executor-hours | Rising while runtime is flat — retries, idle executors, or re-reads |
Do not compare today's slow run against "how it feels." Feelings are not a baseline. If you have no historical metrics, your first deliverable from this incident is not a fix — it is the instrumentation described in Part XXIX, so that the next incident is five minutes of comparison instead of two hours of archaeology.
"The pipeline SLA is 90 minutes. Yesterday it took 88. Today it took 140 and the on-call paged you. Where do you start?"
- Weak
- "Check if the cluster was smaller today."
- Good
- "Compare today's Spark UI to yesterday's — look for a stage that got much slower."
- Senior
- "I'd first split the 140 minutes into scheduler time and Spark time — a lot of 'Spark got slower' is actually 'we waited 50 minutes for an upstream table.' If the Spark portion really grew, I compare the two runs on a fixed set of counters: input bytes, output rows, shuffle read/write, executor-hours, task count, and task p50/p95/max for the top three stages. I'm looking for the metric that moved out of proportion to input — that ratio is the diagnosis. If shuffle quadrupled on flat input, I diff the physical plans, because something almost certainly stopped broadcasting. If input doubled, it's not a Spark problem at all and I go talk to the upstream owner."
Know where to look.
Spark exposes about eight distinct debugging surfaces, and each one answers a different class of question. Engineers who are slow at debugging are usually not missing knowledge — they are looking at the wrong surface. You cannot find a skewed key in a driver log, and you cannot find an expired credential in the Stages tab.
1 · Scheduler / orchestrator — pipeline delay ≠ Spark delay
Start here, always, and it takes thirty seconds. The orchestrator knows things Spark does not: when the task was queued versus started, how long it waited on upstream sensors, how many times it retried, whether a cluster had to be created, and what parameters were actually passed.
- Queued at / started at / ended at — three timestamps, not one duration.
- Retry count — a "slow" run that is actually attempt 3 of 3 has a completely different story.
- Upstream dependency wait — sensor time, poke intervals, data-availability delay.
- Cluster creation time — cold-start cost is real and is often 20–40% of short pipelines Platform-dependent.
- Parameters passed — the date range, the backfill flag, the "reprocess" switch someone left on.
- SLA miss history — is this the first breach or the fourth this month?
2 · The Jobs tab — the map, not the territory
The Jobs tab answers exactly four questions, and it answers them faster than anything else:
- How many actions did my code trigger? One job per action. Twelve jobs where you expected two means you are materialising something repeatedly — a
count()in a log line, ashow()left in, a loop. - Which job is running now, and for how long?
- Which stages were skipped? Skipped stages are good news: they mean shuffle output was reused rather than recomputed. A stage you expected to be skipped but which ran again means the reuse did not happen.
- Which stages failed, and how many attempts? Repeated stage attempts are the signature of a fetch-failure retry storm (Part XVIII), and they inflate runtime without any single task looking slow.
Spark cuts a new stage at every shuffle. Within a stage, operations are fused by whole-stage code generation and run pipelined over each partition — so a stage is the smallest unit of work whose cost you can actually attribute. That is why "which stage" is a more useful question than "which operator": operators inside a stage share one timing.
Consequence: if your slow stage contains a scan, three filters, a projection and a partial aggregate, the UI will not tell you which of those five is expensive. To split them you must either look at the SQL tab's per-node metrics, or change the query so the suspect operator lands in its own stage.
3 · The Stages tab — where you will spend most of your life
Open the slow stage and you get a summary-metrics table with percentile columns, followed by a per-task table. This is the single richest artefact in Spark debugging. Below is a conceptual rendering of what those columns mean and which comparisons matter — the shape of the page, not a screenshot of it.
The metrics worth learning by name
| Metric | What it actually measures | What a bad value tells you |
|---|---|---|
| Duration | Task execution wall clock | Nothing on its own — always read it against data volume |
| Input Size / Records | Bytes and rows read from a data source | Bigger than expected → pruning failed or the source grew |
| Output Size / Records | Bytes and rows written | Grows faster than input → cardinality explosion (Part IX) |
| Shuffle Read Size / Records | Data fetched from other executors' map output | Max ≫ median → skew. Total ≫ input → the plan moves too much |
| Shuffle Write Size / Records | Data written for downstream stages | Huge write on a small stage → an unnecessary repartition or exchange |
| Spill (memory) / Spill (disk) | Data evicted from execution memory to disk | Non-zero at scale → partitions do not fit; the honest memory signal |
| GC Time | JVM pause time attributed to the task | High fraction of duration → allocation pressure (Part VI) |
| Scheduler Delay | Time between task being sent and starting, plus result return | High → scheduling/placement pressure, or a driver too busy to dispatch |
| Task Deserialization Time | Unpacking the task closure on the executor | High → a fat closure; you are shipping data inside the task |
| Result Serialization Time / Getting Result Time | Packing and returning results to the driver | High → returning too much to the driver (Part X) |
| Shuffle Read Blocked Time | Time a task spent waiting for remote blocks | High → network, remote executor pressure, or oversized blocks |
| Peak Execution Memory | High-water mark of execution memory for the task | Close to the budget → spill is imminent |
| Locality Level | PROCESS_LOCAL → NODE_LOCAL → RACK_LOCAL → ANY | Mostly ANY on an HDFS-style cluster → placement lost; on object storage this is normal and not a problem |
| Failed / Killed tasks | Attempts that did not complete | Any recurring failure is a root cause you have not found yet |
The Spark REST API exposes the same summary metrics as JSON, which is how you build baselines and regression checks instead of eyeballing screenshots:
# task quantiles for one stage attempt
curl -s "$SPARK_UI/api/v1/applications/$APP_ID/stages/$STAGE/$ATTEMPT/taskSummary?quantiles=0.5,0.75,0.95,0.99,1.0"
# every stage, with input/shuffle/spill totals
curl -s "$SPARK_UI/api/v1/applications/$APP_ID/stages"
# what the job ACTUALLY ran with — settle every "but the default is…" argument here
curl -s "$SPARK_UI/api/v1/applications/$APP_ID/environment"
Against a running application $SPARK_UI is the driver's UI; against a finished one it is the History Server. Same paths.
4 · Tasks — six distributions, six different problems
Everything above reduces to reading one histogram: the distribution of task durations within a stage, cross-referenced against the distribution of task input. There are six shapes worth recognising on sight.
5 · The SQL tab — five plans, and only two of them are real
Catalyst produces a sequence of plans, and engineers routinely argue about behaviour by quoting the wrong one. The parsed and analysed plans describe what you asked for. The optimized logical plan describes what Catalyst decided you meant. The physical plan describes what it intends to run. And under AQE, the plan that actually ran can differ from all four, because AQE rewrites it mid-flight using real runtime statistics.
EXPLAIN shows intent. The SQL tab shows reality.Running EXPLAIN in a notebook and concluding "it broadcasts, so we're fine." With AQE enabled Apache default since 3.2.0, the physical plan printed before execution is a proposal. AQE may coalesce partitions, convert a sort-merge join to a broadcast join once it sees the real size of one side, or split a skewed partition — none of which appear in a static EXPLAIN.
Conversely, a plan that looks like it broadcasts may not, if the build side turns out larger at runtime than the optimizer's estimate. Always confirm against the SQL tab of the finished run. The final plan there is annotated with what actually happened, including AQE nodes.
Operators worth recognising instantly
| Operator | What it means | When it deserves suspicion |
|---|---|---|
Scan parquet / FileScan | Reading a data source | Check number of files read, size of files read, and whether PartitionFilters and PushedFilters are populated. Empty PartitionFilters on a partitioned table = full scan. |
Filter | A predicate evaluated in-engine | A filter that appears above a join, when it could have been pushed below it |
Project | Column selection / expression evaluation | Projecting far more columns than the query needs (the SELECT * tax) |
Exchange | A shuffle — data moves across the network | See the note below. Not automatically bad. |
Sort | Ordering, usually feeding a sort-merge join or a window | A global sort with a single partition; sorts that could be avoided by a different join strategy |
HashAggregate | Grouping — appears twice, partial then final | Only the final aggregate present → no partial pre-aggregation, so full rows shuffled |
BroadcastExchange + BroadcastHashJoin | One side collected to the driver and shipped to every executor | Build side larger than you think; repeated broadcasts of the same table |
SortMergeJoin | Both sides shuffled by key and merged | Fine for large-to-large. Suspicious when one side is tiny — stats may be stale |
ShuffledHashJoin | Both sides shuffled, one built into a hash map | Reasonable when one side fits in memory per partition and sorting is not needed |
BroadcastNestedLoopJoin | No usable equality condition — nested loops over a broadcast side | Always investigate. Usually a missing or non-equi join condition |
CartesianProduct | Every row against every row | Almost always a bug. Output rows = left × right |
Window | Windowed computation, preceded by a shuffle and sort | Unbounded frames, many distinct window specs, or partitioning by a low-cardinality key |
Generate | explode() and friends | Row multiplication — check output rows against input rows (Part XX) |
BatchEvalPython / ArrowEvalPython | The Python UDF boundary | Rows leave the JVM. BatchEvalPython is row-at-a-time; ArrowEvalPython is vectorised (Part XI) |
ReusedExchange / ReusedSubquery | A shuffle or subquery computed once and reused | Good news when present; its absence where you expected it means you are computing something twice |
AQEShuffleRead | AQE coalescing or splitting shuffle partitions at runtime | Its presence confirms AQE acted; read its metrics to see what it decided |
Every join on a large pair of tables, every wide aggregation, every repartition, and every window over a partition key requires data with the same key to end up in the same place. That is what an Exchange does. Removing it is not a goal in itself; a job with zero exchanges that scans ten times more data is slower, not faster.
An Exchange deserves attention when it is avoidable (a broadcast would do, the data is already partitioned compatibly — Part XIV), oversized (you are shuffling columns the downstream operator never reads), or repeated (the same exchange computed twice because reuse failed). Those three questions, not the presence of the word, are the diagnosis.
"You run EXPLAIN and see a BroadcastHashJoin. In production the same query does a SortMergeJoin and takes 40 minutes. How is that possible?"
- Weak
- "The broadcast threshold must be too low in production."
- Good
- "Probably different data volumes — the table is bigger in prod than in my dev sample, so it exceeds the broadcast threshold."
- Senior
- "Several mechanisms could produce that, and I'd distinguish them from evidence. The optimizer picks broadcast from an estimate: file sizes plus whatever catalog statistics exist. If prod stats are stale or absent, the estimate can be wildly wrong in either direction. If the estimate exceeds
spark.sql.autoBroadcastJoinThreshold, you get a sort-merge join. It also matters whether the build side is behind a filter the optimizer can't estimate, and whether AQE later converted the join — AQE can promote a sort-merge join to broadcast once it measures the real shuffle output, so the final plan in the SQL tab may differ from both. I'd check the SQL tab of the prod run for the actual join node, look at the build-side size metric, runANALYZE TABLE … COMPUTE STATISTICSif stats are missing, and only then consider a hint. And I'd be careful: forcing a broadcast on a table that quietly grows is how you turn a slow job into a driver OOM."
Reading Spark logs like an engineer.
The Spark UI is a profiler. It will tell you that stage 14 took 71% of the run and that one task read 41 GB. It will not tell you that a Python worker segfaulted in a native library, that a token expired at minute 55, or that the executor was killed by the kernel's OOM killer. That is what logs are for, and logs are the half of Spark debugging that most engineers skip.
Six log surfaces, six different jobs
| Log | Where it lives | What only it can tell you |
|---|---|---|
| Driver log | Driver stdout/stderr; the orchestrator usually captures it | The final exception, plan compilation, broadcast decisions, scheduler behaviour, DAGScheduler messages, why the job aborted |
| Executor logs | Per-executor stdout/stderr, retrievable from the Executors tab while alive; from the cluster manager afterwards | The original exception, before it was wrapped and shipped to the driver. Also spill messages, block-manager activity, and fetch errors |
| Cluster-manager logs | YARN NodeManager / ResourceManager, Kubernetes events and pod descriptions, standalone worker logs | Who killed the container and why: memory limit exceeded, node drained, preemption, spot reclamation, eviction |
| JVM GC log | Only if you enabled it via spark.executor.extraJavaOptions | Pause durations and frequency, heap occupancy after collection — the difference between "GC is busy" and "the heap is genuinely full" |
| Python worker output | Interleaved into executor stderr | The Python traceback, native-library crashes, and the memory the worker was using when it died |
| Event log | Written to spark.eventLog.dir when spark.eventLog.enabled=true Apache default: false | The complete structured record of the run — every task, every metric — replayable in the History Server long after the cluster is gone |
In open-source Apache Spark, spark.eventLog.enabled defaults to false. Most managed platforms turn it on for you Platform-dependent, but "most" is not "yours." Check it in the Environment tab today, not during the next incident. A cluster that has terminated takes its UI with it; the event log is the only thing that survives.
How a failure actually propagates
The exception you are shown is almost never the exception that happened. Understanding the chain is what stops you from fixing the wrong thing.
- Find the last exception in the driver log. Note the stage and task id.
- Walk the
Caused by:chain downward. Stop at the first frame that names a resource or a value, not a Spark internal. - If the chain bottoms out in a Spark internal (
FetchFailed,ExecutorLost, heartbeat), the cause is on another host. Get the executor id and host from the message and open that executor's log. - In the executor log, search backwards from the end for the first ERROR or the first WARN that is not routine — and for the last thing the JVM printed before it stopped.
- If the executor log just stops mid-sentence, the process was killed from outside. Go to the cluster-manager log. Spark did not throw; something took the container away.
- Record the timestamps. Correlate against the stage timeline — a failure at the start of a stage and a failure at 95% of a stage have different cause distributions.
Spark 4.x moved toward structured (JSON) logging for driver and executor logs, which makes logs queryable — you can filter by exception.class, executor_id or task_id instead of grepping free text. Version-dependent: whether it is on by default, and the exact field names, vary by release and by distribution, and many platforms override the logging configuration entirely Platform-dependent. Check what your build emits before writing parsers against it. The content of the messages below is stable across both formats.
The error catalogue
What follows is not a list of error strings to memorise. It is a list of mechanisms, each with the knee-jerk fix that usually makes things worse and the correction that usually works.
1 · java.lang.OutOfMemoryError: Java heap space
ERROR Executor: Exception in task 47.0 in stage 9.0 (TID 3312)
java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3745)
at org.apache.spark.sql.catalyst.expressions.codegen.BufferHolder.grow(...)
This is a genuine JVM heap exhaustion, and where it is thrown decides everything:
- Executor OOM — a single partition's working set does not fit. Usually too much data per task, an aggregation with an enormous number of groups, an oversized broadcast on the receiving side, or a single monstrous record.
- Driver OOM — the stack trace mentions
collect,toPandas,BroadcastExchange,TaskSetManager, or plan objects. The coordinator was asked to hold data or metadata it cannot hold. Part X.
Double spark.executor.memory. It sometimes works, at double the cost, and it fails again the moment the data grows. Worse, a bigger heap often means longer GC pauses (Part VI), so you can trade an OOM for a heartbeat timeout.
Find out why one task's working set is large. If max shuffle read ≫ median, it is skew (Part VII) and no amount of memory is enough. If all tasks are large, increase parallelism so each partition is smaller. If it is an aggregation, check the group cardinality. If it is a broadcast, check the build-side size. Memory is the correct answer only when the per-task working set is genuinely irreducible.
2 · Container killed for exceeding memory limits
WARN YarnAllocator: Container killed by YARN for exceeding physical memory limits.
16.9 GB of 16 GB physical memory used. Consider boosting spark.executor.memoryOverhead.
# on Kubernetes the equivalent is a pod with:
# State: Terminated Reason: OOMKilled Exit Code: 137
This is not a Java heap OOM. Nothing threw an exception; the cluster manager or the kernel killed the whole process because the container exceeded its limit. The heap may have been half empty. What overflowed was everything outside the heap:
- JVM non-heap: metaspace, thread stacks, code cache, direct byte buffers used by shuffle and networking
- Python worker processes (in PySpark, these are separate OS processes, entirely outside the JVM heap)
- Native libraries — compression codecs, Arrow buffers, native BLAS, anything JNI
- Off-heap memory, if you enabled it
Increase spark.executor.memory. On a fixed-size container this makes the problem worse: the heap grows and squeezes the very non-heap region that overflowed.
Identify which non-heap consumer grew. In PySpark, the usual answer is the Python workers — cap them with spark.executor.pyspark.memory, reduce spark.executor.cores (fewer concurrent Python processes per container), or move the work out of Python. If it is genuinely overhead, raise spark.executor.memoryOverhead (or the overhead factor) and the container size together. Part V has the full anatomy.
3 · GC overhead limit exceeded
java.lang.OutOfMemoryError: GC overhead limit exceeded
The JVM gave up: it spent an overwhelming fraction of recent time collecting garbage and recovered almost nothing. Practically, the heap is full of live objects. This is a heap-sizing or allocation-rate problem, and it means the same thing as a heap OOM but arrives earlier and more politely. Treat it exactly like case 1 — plus check whether cached datasets are pinning the heap (Part XV).
4 · ExecutorLostFailure
ERROR TaskSchedulerImpl: Lost executor 17 on 10.0.4.91:
ExecutorLostFailure (executor 17 exited caused by one of the running tasks)
Reason: Container marked as failed … Exit status: 137
"An executor disappeared." That is all it says. The cause is one of at least seven things, and the message does not distinguish them:
- Heap OOM inside the executor (look for the OOM in that executor's own log)
- Container/pod killed for exceeding a memory limit (exit 137 is the classic tell)
- Kubernetes eviction — node pressure, or a higher-priority pod
- YARN preemption, or the node manager going away
- Node failure, or spot/preemptible instance reclamation Platform-dependent
- Network partition — the driver stopped hearing heartbeats and declared it dead
- Local disk failure or disk-full, which takes the executor down with it (Part XIX)
- Graceful decommissioning during a scale-down
Exit 137 → killed for memory. Exit 143 → SIGTERM, usually decommission or preemption. Executor log ends with an OOM stack → heap. Executor log ends mid-line with no error → killed from outside; go to the cluster-manager events. Several executors lost at the same instant → node or infrastructure event, not your code. One executor lost repeatedly → that host is bad.
5 · FetchFailedException
org.apache.spark.shuffle.FetchFailedException:
Failed to connect to ip-10-0-4-91.ec2.internal/10.0.4.91:7337
… at org.apache.spark.storage.ShuffleBlockFetcherIterator.throwFetchFailedException
A reduce task tried to fetch shuffle blocks produced by a map task and could not. This is the single most misdiagnosed error in Spark, because it is almost always a consequence. The possible causes:
- The executor that held the shuffle blocks died (so the real error is case 2 or 4, on another host)
- Local disk failure or disk-full where the shuffle files were written
- Genuine network problems — saturation, packet loss, security-group or firewall changes
- An external shuffle service that is overloaded or was restarted Platform-dependent
- Individual shuffle blocks so large that transfers time out — the skew signature again
What makes it expensive is the cascade: when a fetch fails, Spark must re-run the map stage that produced the missing blocks, because that output is gone. On a large job this can mean re-executing an hour of work, which then puts more pressure on the same resources, which produces more fetch failures. That is a retry storm, and it is why a fetch-failure incident often shows a job that ran for four hours and produced almost nothing.
Raise spark.shuffle.io.maxRetries and spark.shuffle.io.retryWait and hope. More retries against a dead executor is more waiting, not more success. If the executor died because of memory, retrying the fetch cannot resurrect it. You have converted a fast failure into a slow one.
Find out why the block was unavailable. Open the Executors tab and look for executors that died around the failure time; open their logs. If they died of memory, fix the memory cause. If shuffle blocks are enormous, reduce partition size so blocks shrink. If the same host recurs, that host is unhealthy. If an external shuffle service is in play, its logs are the next stop. Retry settings are a resilience knob for genuinely transient conditions — not a diagnosis.
6 · Executor heartbeat timed out
ERROR HeartbeatReceiver: Removing executor 23 with no recent heartbeats:
152341 ms exceeds timeout 120000 ms
The driver stopped hearing from an executor for longer than spark.network.timeout Apache default 120s and declared it dead. The executor may have been perfectly alive and simply unable to answer, because:
- It was in a multi-minute stop-the-world GC pause (the most common cause — Part VI)
- It was CPU-starved: more concurrent tasks than cores, or a noisy neighbour on the host
- The network hiccupped, or the driver itself was too busy to process heartbeats
- It was swapping, or its local disk was saturated
Raise spark.network.timeout to 600s. This is the "larger waiting room" fix from Part XVIII: you have not made the patient healthier, you have made yourself slower to notice they are sick. Occasionally justified — on a legitimately high-latency network, or during a known long pause you cannot yet eliminate — but never as step one.
Look at that executor's GC time as a fraction of its run time in the Executors tab. If it is high, you have a GC problem; fix the allocation pressure. If GC is fine, check host CPU and disk metrics — you are probably oversubscribed. The timeout is a detector, not a cause.
7 · Task serialization failures
org.apache.spark.SparkException: Task not serializable
Caused by: java.io.NotSerializableException: com.acme.pipeline.DbConnection
# or, from the driver, before anything runs:
WARN TaskSetManager: Stage 3 contains a task of very large size (18234 KiB).
The maximum recommended task size is 1000 KiB.
NotSerializableException means your closure captured something that cannot cross the wire — a database connection, a logger, a Spark context, or (most often) this, because you referenced an instance field from inside a lambda. The fix is to capture only what the closure needs: pull the value into a local variable first, or construct the non-serializable object inside the task (typically per partition, via mapPartitions).
A very large task means you are shipping data inside the closure — a big lookup map, a config blob, a collected list. Broadcast it instead, or read it from storage on the executor. Large tasks also inflate "Task Deserialization Time" in the stage metrics, which is how you spot it from the UI.
8 · Kryo buffer overflow
org.apache.spark.SparkException: Kryo serialization failed: Buffer overflow.
Available: 0, required: 4194305.
To avoid this, increase spark.kryoserializer.buffer.max value.
A single object was too large for the Kryo serialization buffer. Raising spark.kryoserializer.buffer.max is the documented remedy and is often correct — but ask why a single object is multiple megabytes first. Common answers: a giant array aggregated with collect_list, an enormous map built per key, or a monolithic record. Those are usually data-model problems that will keep growing. Version-dependent: check spark.serializer in the Environment tab rather than assuming which serializer your job used.
9 · Python worker exited unexpectedly (crashed)
org.apache.spark.SparkException: Python worker exited unexpectedly (crashed)
Caused by: java.io.EOFException
# — and in the same executor's stderr, minutes earlier:
# Fatal Python error: Segmentation fault
# or nothing at all, if the kernel killed it
A PySpark worker process died. Three usual causes: (a) it was killed for memory — the JVM heap was fine but the Python process pushed the container over its limit; (b) a native library segfaulted; (c) an unhandled crash inside a UDF on a specific record. Distinguish by looking for a Python traceback (case c), a segfault line (case b), or nothing at all plus a container-kill event (case a). Note that the JVM-side EOFException is only telling you the pipe closed — it is never the root cause.
10 · Schema, metadata and file errors
AnalysisException: Column 'customer_segment' does not exist. Did you mean 'customer_seg'?
java.io.FileNotFoundException: … part-00042-….snappy.parquet
It is possible the underlying files have been updated. You can explicitly invalidate
the cache in Spark by running 'REFRESH TABLE tableName' command in SQL…
org.apache.parquet.io.ParquetDecodingException: Can not read value at 0 in block -1
AnalysisException is the cheapest failure in Spark: it happens before any work, and it usually means upstream changed a schema. FileNotFoundException almost always means someone rewrote the data while you were reading it — a classic overwrite-in-place race that transactional table formats exist to prevent. A decoding exception points at a genuinely corrupt or truncated file, often from a writer that failed mid-flight.
11 · Permission and credential failures
Two shapes, and they mean opposite things. A failure at the start is a misconfiguration — wrong role, wrong bucket policy, wrong path. A failure 55 minutes into an hour-long job is almost always a credential that expired mid-run, and the fix is in how credentials are refreshed, not in Spark. The tell is that the same job succeeds when it runs faster.
12 · Total size of serialized results … is bigger than spark.driver.maxResultSize
ERROR TaskSetManager: Total size of serialized results of 1841 tasks (1024.3 MiB)
is bigger than spark.driver.maxResultSize (1024.0 MiB)
Someone called collect(), or toPandas(), or a wide take(). Apache default: spark.driver.maxResultSize = 1g. This guard exists specifically to fail your job before it kills the driver. Raising it is occasionally right and usually a mistake — see Part X, which is dedicated to the driver.
The error triage table
| Error message | What it usually means | Where to verify | Dangerous knee-jerk fix | Correct first move |
|---|---|---|---|---|
OutOfMemoryError: Java heap space | One task's working set exceeded the heap | Which side threw it (driver vs executor); stage task max-vs-median for shuffle read | Double executor memory | Determine skew vs uniform. If skew, fix the key distribution; if uniform, raise parallelism |
Container killed / OOMKilled / exit 137 | Total container memory exceeded — often non-heap | Cluster-manager events; PySpark worker memory; off-heap settings | Raise executor.memory inside the same container | Find the non-heap consumer. Cap Python memory or reduce cores per executor |
GC overhead limit exceeded | Heap effectively full of live objects | Executors tab GC time; cached RDD/DataFrame storage | Switch GC algorithm | Reduce live set: unpersist caches, shrink partitions, remove object churn |
ExecutorLostFailure | An executor disappeared — cause is elsewhere | That executor's log; exit code; cluster-manager events | Increase spark.task.maxFailures | Read the lost executor's own log; classify by exit code |
FetchFailedException | Shuffle blocks unreachable — usually a dead executor | Executors tab for deaths near the timestamp; block sizes; host metrics | Increase fetch retries and timeouts | Find why the block source went away; shrink oversized shuffle blocks |
| Heartbeat timed out | Executor could not answer in time | GC time fraction on that executor; host CPU and disk | Raise spark.network.timeout | Check GC and CPU oversubscription first |
NotSerializableException | Closure captured a non-serializable object | The named class in the trace | Make everything Serializable | Capture locals only; build the object inside mapPartitions |
| Task of very large size | Data is travelling inside the closure | Task deserialization time in stage metrics | Ignore the warning | Broadcast the lookup, or read it on the executor |
| Kryo buffer overflow | One object too large to serialize | What is being collected into a single value | Raise the buffer and move on | Raise the buffer and ask why a single object is that large |
| Python worker crashed | Worker process died — memory or native fault | Executor stderr for traceback / segfault; container events | Retry the job | Separate memory-kill from segfault from record-specific crash |
AnalysisException | Schema does not match expectation | Source table schema history | Add a cast to silence it | Find the upstream change; add a contract/test so it fails at the source |
FileNotFoundException on read | Files changed underneath the query | Upstream write times; table format in use | Add a retry loop | REFRESH TABLE for stale cache; fix the overwrite race properly |
| Permission / credential denied | Wrong identity, or a token that expired mid-run | Failure timestamp vs job start | Grant broader permissions | If late in the run, fix credential refresh, not the policy |
maxResultSize exceeded | Too much data returned to the driver | The action in the stack trace | Raise maxResultSize | Stop collecting. Write to storage, aggregate first, or sample |
"Your job fails with FetchFailedException. A teammate proposes raising spark.shuffle.io.maxRetries from 3 to 10. What do you say?"
- Weak
- "Sounds reasonable, retries usually help with flaky networks."
- Good
- "I'd want to check whether an executor died first — retries won't help if the data is gone."
- Senior
- "A fetch failure means a shuffle block was unreadable, and retries only help if the block is still there and the failure was transient. So the first question is whether the executor holding it is alive. I'd open the Executors tab, find deaths around that timestamp, and read the dead executor's log — if it was OOM-killed, then no retry count fixes it, and worse, each fetch failure forces Spark to re-run the map stage, so we'd be paying that re-execution repeatedly. I'd also check the shuffle block sizes: if the max is orders of magnitude above the median, transfers are timing out because of skew, and the fix is partition sizing, not retry policy. Raising retries is a legitimate resilience setting on a genuinely lossy network — but as the first response to a fetch failure it converts a fast failure into an expensive one and destroys the evidence."
Memory engineering.
Almost every wrong Spark fix starts with a misunderstanding of one picture: what is actually inside an executor container. spark.executor.memory does not describe the container. It describes one region inside the container, and several of the most common failures happen in the regions it does not describe at all.
spark.executor.memory is one box out of eight.The five settings, and what each one actually moves
| Setting | Controls | Raise it when | Raising it is wrong when |
|---|---|---|---|
spark.executor.memory | The JVM heap of each executor | Per-task working set is genuinely large and irreducible; heavy caching is intentional | The failure was a container kill; the problem is skew; GC pauses are already long |
spark.executor.memoryOverhead | Extra container memory outside the heap | Container kills with a healthy heap; heavy off-heap/native/shuffle buffer use | You did not also raise the container size — on some managers, overhead comes out of the same budget Platform-dependent |
spark.executor.pyspark.memory | A budget for Python worker processes (when set) | PySpark jobs where workers are the non-heap consumer | The job is Scala/SQL only — it changes nothing |
spark.memory.offHeap.enabled / .size | Off-heap execution memory Apache default: disabled | You want large execution memory without long GC pauses, and you have sized the container for it | You enabled it without adding container room — you just shrank effective memory |
spark.memory.fraction | Share of usable heap given to execution+storage Apache default 0.6 | Almost never — it is one of the last knobs, not one of the first | You are compensating for user-memory pressure caused by your own objects; fix the objects |
spark.memory.storageFraction | The floor of unified memory guaranteed to cache Apache default 0.5 | You cache deliberately and eviction is measurably hurting | You are caching things you should not cache (Part XV) |
Memory per task, not per executor, is what matters. An executor with 16 GB of heap running 5 concurrent tasks gives each task roughly a fifth of the unified memory. Doubling the heap to 32 GB while leaving cores at 5 doubles per-task memory — but so does halving the cores, at no extra cost. And doubling the number of partitions halves the data each task must hold, which is usually better than either.
There is also a ceiling: bigger heaps mean bigger collections. Beyond a certain size, adding heap buys you longer stop-the-world pauses, which cause heartbeat timeouts, which cause executor loss, which causes fetch failures. You can absolutely make a job less stable by giving it more memory.
# 1. Which JVM threw it? Search the DRIVER log:
grep -n "OutOfMemoryError" driver.log | head
# stack contains collect/toPandas/Broadcast → driver problem
# stack contains Executor/task threads → executor problem
# 2. Was it a kill rather than an exception? Kubernetes:
kubectl describe pod | grep -A3 "Last State"
# Reason: OOMKilled, Exit Code: 137 → container limit, NOT heap
# 3. Skew or uniform? REST API, no UI squinting required:
curl -s "$UI/api/v1/applications/$APP/stages/$STAGE/0/taskSummary?quantiles=0.5,0.95,1.0"
# compare shuffleReadMetrics.readBytes at 0.5 vs 1.0
"A PySpark job keeps getting its executors killed with exit code 137. The heap dump shows the JVM heap was only 40% used. What is happening?"
- Weak
- "Memory leak — increase executor memory."
- Good
- "It's a container kill, not a heap OOM. Probably memory overhead — I'd increase
memoryOverhead." - Senior
- "Exit 137 with a half-empty heap means the container limit was hit by something outside the JVM heap, and in PySpark the prime suspect is the Python workers: there is one worker process per concurrent task, so an executor with 8 cores can be running 8 Python processes, each holding its own copy of whatever the UDF materialises. I'd check whether we're using a pandas/Arrow UDF with a large batch size, or something that pulls a whole partition into Python. The cheapest fix is usually reducing
spark.executor.cores— fewer simultaneous workers per container — which also gives each remaining task more JVM memory. Thenspark.executor.pyspark.memoryto make the budget explicit, and raising overhead plus the container size together if it's genuinely needed. And I'd ask whether the UDF needs to be Python at all, because moving it to a native expression removes the whole category."
Garbage collection.
GC is not a Spark feature; it is a tax on how you allocate. Spark itself is fairly careful — Tungsten's binary formats exist precisely to keep data out of the Java object graph. GC problems in Spark are therefore almost always caused by something you introduced: caching, wide rows materialised as objects, UDF churn, or simply too much data per executor.
The conceptual model
- Minor GC collects the young generation, where short-lived objects live. It is frequent and normally cheap. A Spark task allocates furiously and discards almost everything, so minor GCs are expected and fine.
- Major / full GC deals with long-lived objects. It is rarer, far more expensive, and on large heaps can stop the JVM for seconds — occasionally for minutes.
- Allocation pressure is the rate at which you create objects. High allocation with short lifetimes stresses the young generation; high allocation with long lifetimes promotes objects into the old generation and eventually forces full collections.
- Cache pressure is the special case that catches everyone: cached blocks are long-lived by definition. Cache a large dataset and you have deliberately filled the old generation with objects the collector must scan and cannot free.
The one number that matters
Both numbers are in the Executors tab: Task Time (GC Time) is rendered as a pair. Compute the ratio per executor, not for the cluster.
Worked example. An executor reports Task Time 4.2 h and GC Time 1.9 h. That is 45%. Nearly half of everything that executor did was collect garbage. Its useful throughput is roughly half of what you are paying for, and it is a prime candidate for the next heartbeat timeout.
Another. Task Time 6.0 h, GC Time 0.2 h → 3.3%. Healthy. Do not spend a minute on GC here; the bottleneck is elsewhere.
Workload-dependent heuristic Below roughly 5% is fine; 5–10% is worth noting; above 10–15% is worth investigating; above 25% is a defect. These bands are not thresholds from the documentation — they are a rule of thumb about where the ratio starts to dominate. A job doing heavy caching by design may sit at 12% and be perfectly tuned; a lightweight scan-and-write job at 12% has something badly wrong.
What actually causes it
| Contributor | Mechanism | What to do instead of tuning GC |
|---|---|---|
| Caching large datasets | Cached blocks are long-lived and must be scanned by every major collection | Cache less, cache narrower (project columns first), or use a disk-inclusive storage level. Part XV |
| Oversized heaps | More heap to scan, longer pauses, worse worst case | Prefer more executors with moderate heaps over few executors with huge heaps workload-dependent |
| Object churn in UDFs | Row-at-a-time Python or Scala UDFs allocating per record | Use built-in expressions; if you must use Python, use Arrow-based UDFs (Part XI) |
| Too many cores per executor | N concurrent tasks all allocating into one heap | Reduce cores per executor; run more, smaller executors |
| Wide rows and nested structures | Deserialised objects far larger than their encoded form | Project only needed columns; flatten hot paths; avoid materialising nested blobs |
| Huge aggregation state | High-cardinality GROUP BY building enormous hash maps | Pre-aggregate, reduce key cardinality, or use approximate sketches |
Switching collectors or hand-tuning generation sizes is a legitimate last-mile technique, and it is almost never the right first move. The reason is simple: GC tuning changes how efficiently the JVM manages a live set you have not yet tried to reduce. If your executor is spending 45% of its time collecting because you cached a 400 GB DataFrame you read once, no collector setting fixes that.
GC tuning becomes reasonable only after you have excluded the workload-level causes above and you can show, from GC logs, that pause behaviour rather than live-set size is the constraint. If you do change it, change one thing, measure, and write down why — see Part XXIV.
"An executor shows Task Time 4.2 h and GC Time 1.9 h. Walk me through it."
- Weak
- "GC is high, so I'd switch to G1GC."
- Good
- "That's about 45% GC, which is far too high. I'd look at whether we're caching too much and reduce the heap pressure."
- Senior
- "45% means nearly half the executor's paid time produced nothing, and it also puts us one long pause away from a heartbeat timeout and an executor loss — so a GC problem often shows up in the incident log as a fetch failure, which is why people misdiagnose it. I'd check three things in order: is anything cached that shouldn't be, and does the Storage tab show it pinning most of the heap; are the pauses growing over the stage, which points at a live set that accumulates; and how many cores per executor, since N concurrent tasks allocating into one heap multiplies the pressure. The fixes I'd try are unpersisting, projecting fewer columns before the cache, and reducing cores per executor — all of which shrink the live set. I'd only look at collector settings after those, and only with GC logs showing that pause behaviour, not live-set size, is the limit."
Data skew.
Spark's entire performance model rests on one assumption: that work divides evenly. Skew is the violation of that assumption, and it is the single most common cause of the "job stuck at 99%" incident. A job sitting at 99% is not necessarily short of compute. It may have 999 perfectly healthy tasks waiting for one pathological partition.
Nine flavours of skew, and where each comes from
| Flavour | Typical shape | Where it hides |
|---|---|---|
| Skewed join keys | One or a few key values with vastly more rows on one or both sides | A "power user", a platform account, an internal test tenant |
| Skewed GROUP BY | One group holds a large fraction of all rows | Grouping by a status, a country, a device type with a dominant value |
| NULL skew | Every NULL key hashes to the same partition | Optional foreign keys. Note: in an inner equi-join NULL keys never match, so they are shuffled and then discarded — pure waste |
| Sentinel / default IDs | 0, -1, 'UNKNOWN', 'N/A', empty string | Upstream systems that refuse to emit NULL. Often the single biggest key in the table |
| Time-based skew | One date/hour partition far larger than the rest | Backfills, a launch day, a batch of late-arriving data landing in one bucket |
| Geographic skew | One region or city dominating | Partitioning or grouping by geography in a business that is concentrated |
| Tenant / customer skew | The largest customer is 100× the median customer | Every multi-tenant system, always. Plan for it from day one |
| Explode-created skew | One input row expands into millions | An array column whose length distribution is heavy-tailed |
| Storage partition skew | One directory holds most of the files or bytes | Reading a partitioned table where one partition value dominates |
How to prove it, not guess it
Level 1 — task duration distribution. Stage summary metrics: is max ≫ median? Cheap, instant, but ambiguous: a straggler node produces the same shape.
Level 2 — shuffle read and record distribution. This is the discriminator. If the slow task also read 100× more data, it is skew. If the slow task read a normal amount and simply took longer, it is a straggler (Part XVII). Never skip this step; it is the difference between fixing your data and rebooting a node.
Level 3 — find the key. Aggregate the suspect join or group key and look at the top values.
Level 4 — confirm the fix changed the distribution, not just the runtime. A runtime improvement with an unchanged max/median ratio means you got lucky, not correct.
-- Level 3: the profiling query you should run before any skew fix.
-- Cheap, and it settles the argument in one shot.
SELECT
merchant_id,
COUNT(*) AS rows,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS pct_of_total
FROM transactions
WHERE ds = '2026-08-26'
GROUP BY merchant_id
ORDER BY rows DESC
LIMIT 25;
-- The shape that tells you everything:
-- merchant_id | rows | pct_of_total
-- -1 | 412,880,113 | 34.10 ← sentinel: 34% of the table in ONE key
-- NULL | 61,204,880 | 5.05 ← null skew
-- 88213 | 35,104,220 | 2.90 ← a genuine whale customer
-- … | ~90 rows | 0.00 ← everyone else
# The same profile in PySpark, plus the metric that actually predicts task size.
from pyspark.sql import functions as F
profile = (df.groupBy("merchant_id")
.agg(F.count("*").alias("rows"),
F.sum(F.length(F.col("payload"))).alias("approx_bytes"))
.orderBy(F.desc("rows")))
profile.show(25, truncate=False)
# Row counts are a proxy. If records vary hugely in WIDTH, bytes per key is the
# better predictor of partition size — a key with 2M narrow rows can be cheaper
# than a key with 200k rows each carrying a large nested array.
Nine remedies, each with the case where it fails
1 · Remove invalid hot keys
If 34% of your rows carry merchant_id = -1, and that value means "unknown", then in an inner join those rows will be shuffled across the network, hashed into one partition, compared, and thrown away. Filtering them before the join deletes the biggest partition entirely.
-- BEFORE: -1 and NULL are shuffled, then discarded by the join anyway
SELECT t.*, m.name FROM transactions t JOIN merchants m ON t.merchant_id = m.merchant_id
-- AFTER: eliminate keys that cannot match, before the exchange
SELECT t.*, m.name
FROM (SELECT * FROM transactions WHERE merchant_id IS NOT NULL AND merchant_id <> -1) t
JOIN merchants m ON t.merchant_id = m.merchant_id
Limitation. Only valid when those rows genuinely cannot contribute to the result. For a LEFT join you must keep them — handle them in a separate branch and union the results back, rather than dropping them.
2 · Separate the hot keys (the two-path pattern)
Split the query into "hot keys" and "everything else", handle each with the strategy that suits it, and union. Hot keys are few, so their side of the join is usually small enough to broadcast.
WITH hot AS (SELECT merchant_id FROM merchant_daily_counts WHERE ds='2026-08-26' AND rows > 50000000)
SELECT /*+ BROADCAST(m) */ t.*, m.name
FROM transactions t JOIN merchants m ON t.merchant_id = m.merchant_id
WHERE t.merchant_id IN (SELECT merchant_id FROM hot)
UNION ALL
SELECT t.*, m.name
FROM transactions t JOIN merchants m ON t.merchant_id = m.merchant_id
WHERE t.merchant_id NOT IN (SELECT merchant_id FROM hot)
Limitation. You now scan the fact table twice unless you cache or persist the split, and the hot-key list must be maintained — a key that becomes hot next month will not be in it. Pair it with the monitoring in Part XXX.
3 · Let AQE handle it
Adaptive Query Execution can detect a skewed shuffle partition at runtime and split it into several smaller ones, replicating the matching side. This is the first thing to check, because it may already be working — or already be silently declining to act.
spark.sql.adaptive.enabled # Apache default: true (since 3.2.0)
spark.sql.adaptive.skewJoin.enabled # Apache default: true
spark.sql.adaptive.skewJoin.skewedPartitionFactor # a partition is "skewed" if it exceeds
# factor × the median partition size
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes # …AND exceeds this absolute size
spark.sql.adaptive.advisoryPartitionSizeInBytes # target size AQE aims for when splitting/coalescing
Both conditions must hold: relative factor and absolute threshold. That is why AQE sometimes "does nothing" about visible skew — the partition is 40× the median but still under the absolute byte threshold, or vice versa. Check the SQL tab for AQEShuffleRead nodes and their skew-partition metrics to see what it actually decided.
Limitation. AQE skew handling applies to shuffle partitions in a join. It does not fix skew inside a single aggregation group, it does not help when one input file or storage partition is enormous, and it cannot help if the skew is created downstream by an explode. Version-dependent: the exact defaults for factor and threshold have changed across the 3.x line — read them from your Environment tab, not from a blog.
4 · Salting
Add a random component to the hot key so it spreads across N partitions, then aggregate in two stages or replicate the small side N ways.
-- Aggregation salting: two-stage, exact result, no replication needed
WITH salted AS (
SELECT merchant_id,
CAST(FLOOR(RAND() * 64) AS INT) AS salt, -- 64 = spread factor
amount
FROM transactions
),
partial AS (
SELECT merchant_id, salt, SUM(amount) AS part_amount
FROM salted GROUP BY merchant_id, salt -- 64× more groups, all small
)
SELECT merchant_id, SUM(part_amount) AS total_amount
FROM partial GROUP BY merchant_id; -- tiny second pass
Limitation. Salting works cleanly for additive aggregations (SUM, COUNT, MIN, MAX). It does not compose for anything requiring the full group at once (exact median, exact COUNT(DISTINCT) without a sketch). For joins, salting the fact table means replicating the dimension side N times, which multiplies its size — spread factor is a real cost, not a free knob.
5 · Deterministic salting
RAND() is non-deterministic, which makes a stage retry produce different partitioning and can make results non-reproducible in some pipelines. Derive the salt from the data instead:
-- deterministic: same input row always lands in the same salt bucket
CAST(pmod(hash(transaction_id), 64) AS INT) AS salt
Limitation. If the column you hash is itself skewed or has low cardinality, your salt will be skewed too — hash something with high cardinality and no correlation to the join key. And unlike RAND(), a deterministic salt gives an uneven spread if the hash source is uneven, so verify the resulting distribution.
6 · Pre-aggregation
Reduce before you shuffle. If you are joining a fact table to a dimension only to aggregate afterwards, aggregate first: shuffling 8 million pre-aggregated rows beats shuffling 4 billion raw ones, and the hot key shrinks proportionally.
Limitation. Only possible when the aggregation does not depend on columns supplied by the join. If the dimension provides a grouping attribute, you cannot pre-aggregate past it — though you can sometimes join a narrow key-mapping first and aggregate immediately after.
7 · Two-stage aggregation
The generalisation of salting: partial aggregate locally, then combine. Spark does this automatically for most built-in aggregations (you will see HashAggregate twice in the plan — partial then final). If the plan shows only one, you have written something that defeats partial aggregation — most commonly a UDAF or a collect-style aggregation.
Limitation. If the number of distinct groups is enormous, the partial aggregate itself becomes the memory problem: you have simply moved a huge hash map from the reduce side to the map side.
8 · Repartitioning
Explicitly redistribute on a better key, or use REBALANCE available from Spark 3.3 which lets AQE pick partition boundaries for you, including splitting skewed ones.
-- SQL hints
SELECT /*+ REBALANCE(merchant_id) */ * FROM wide_output; -- even output partitions
SELECT /*+ REPARTITION(2000, merchant_id) */ * FROM t; -- explicit, hash-partitioned
SELECT /*+ REPARTITION_BY_RANGE(2000, event_ts) */ * FROM t;-- range, good for sorted output
Limitation. repartition on the skewed key does not fix skew — hashing a hot value still sends every row of it to one partition. Repartitioning helps when the current partitioning is bad for an unrelated reason, or when you repartition on a different, better-distributed column. Repartitioning also costs a full shuffle, so it must pay for itself.
9 · Redesign the data model
The remedy nobody wants and the only one that is permanent. If one tenant is 40% of your data, no partitioning scheme will make them behave like the median tenant. Options: give the whale its own physical partition or table; pre-compute their aggregates on a separate schedule; change the grain so the hot key is no longer the join key; or introduce a surrogate that distributes better.
Limitation. Cost and coordination. This is a quarter of work, not an afternoon — which is exactly why it should be raised early, with the evidence, rather than after the fourth salting patch.
- max/median task duration ratio for the affected stage. If it did not fall, you did not fix skew.
- max/median shuffle read. This is the cause; duration is the effect.
- Total shuffle bytes and executor-hours. Some skew fixes (salting with replication, two-path scans) increase total work while decreasing wall clock. That may be a good trade — but you must know you made it. Part XXIII.
"AQE skew join is enabled and you still have a 40-minute straggler task. What's going on?"
- Weak
- "AQE must be broken — I'd disable it and salt manually."
- Good
- "AQE only splits partitions that cross its thresholds. Maybe the partition isn't big enough in absolute bytes to trigger it."
- Senior
- "Several possibilities, and I'd check them in order from the plan. First: is the skew even in a join? AQE's skew handling targets skewed shuffle partitions in joins — it does nothing for a skewed
GROUP BYgroup, and nothing for skew created by anexplodedownstream. Second: AQE needs both its relative factor and its absolute byte threshold to be exceeded, so a partition 40× the median can still be ignored if it's below the byte threshold. Third: the skew may be on the read side — one input file or storage partition that's enormous — which is before any shuffle exists to split. Fourth: if the join was already converted to a broadcast, there's no shuffle partition to split at all. I'd look at the SQL tab forAQEShuffleReadnodes and their skew metrics to see what AQE actually did, and only then decide between adjusting thresholds, salting, isolating the hot key, or fixing the file layout."
Shuffle engineering.
A shuffle is the only operation in Spark that touches every one of the four physical resources at once: CPU to serialize, memory to buffer, disk to stage, and network to transfer. That is why it dominates so many jobs, and why "reduce the shuffle" is such a reliable optimization heading — and also why blindly minimising exchanges can make things worse.
What physically happens
The nine shuffle pathologies
| Pathology | Evidence in the UI | What it actually costs you |
|---|---|---|
| Excessive shuffle | Shuffle write ≫ input bytes across the job | You are moving data you could have reduced, filtered or broadcast first |
| Too few partitions | Task count < slot count; large per-task input; spill non-zero | Idle capacity plus disk I/O you did not need |
| Too many partitions | Enormous task count; median task duration in the tens of milliseconds | Scheduling overhead exceeds useful work; driver pressure; tiny output files |
| Huge shuffle blocks | Max shuffle read ≫ median; fetch failures on the biggest tasks | Transfers that time out and force map-stage re-execution |
| Memory spill | Spill (memory) non-zero | Data serialized out of execution memory — CPU cost before any disk cost |
| Disk spill | Spill (disk) non-zero, often in GB | Extra write + read per spilled byte; local disk saturation (Part XIX) |
| Fetch failures | FetchFailedException; repeated stage attempts | Whole map stages re-executed. The most expensive failure mode in Spark |
| Network saturation | High shuffle read blocked time across all tasks; host network metrics pinned | Everything waits; adding executors makes it worse |
| Local disk pressure | Executor loss with disk-full messages; rising task times as the stage progresses | Executors die, shuffle output dies with them, cascade begins |
AQE changed how you tune this — read this before touching partition counts
The old advice — "set spark.sql.shuffle.partitions to N and tune N by hand" — comes from a Spark that could not see runtime data sizes. AQE can. Understanding the division of labour is what separates current practice from folklore.
| Setting | What it does | Apache default | How to think about it now |
|---|---|---|---|
spark.sql.shuffle.partitions | Number of partitions produced by a shuffle | 200 | With AQE on, this is the initial count before coalescing. Set it generously — high enough that no partition is too big — and let AQE coalesce it back down. Too low is still worse than too high: AQE coalescing only merges, and while AQE skew handling can split an eligible skewed shuffle partition, it will not rescue every form of bad initial partitioning. |
spark.sql.adaptive.enabled | Master switch for adaptive re-planning | true (since 3.2.0; false in 3.0/3.1) | Confirm it is actually on in your run. Some platforms and some legacy job configs disable it. |
spark.sql.adaptive.coalescePartitions.enabled | Merge small post-shuffle partitions | true | This is what makes a generous initial partition count safe. |
spark.sql.adaptive.advisoryPartitionSizeInBytes | Target size AQE aims for when coalescing (and when splitting skewed partitions) | 64 MB | The most useful AQE knob. This is the "size, not count" dial. Raise it for wide, cheap rows; lower it for expensive per-row work. |
spark.sql.adaptive.skewJoin.enabled and its factor/threshold | Split skewed shuffle partitions in joins | true | See Part VII. Both the relative factor and the absolute threshold must be exceeded. |
spark.sql.adaptive.localShuffleReader.enabled | Read shuffle output locally after AQE converts a join to broadcast | true | Leave it on; it removes a network hop after a plan change. |
spark.default.parallelism | Default partition count for RDD operations | total cores (cluster-mode) | Does not control DataFrame/SQL shuffles. A frequent source of confusion — the SQL path uses spark.sql.shuffle.partitions. |
A partition's cost is determined by how much memory its rows occupy while being processed, how expensive each row is to process, how well it compresses on the wire, and how much execution memory each task actually gets — which itself depends on executor memory divided by cores. None of those are constants across workloads.
A stage doing a simple SUM over narrow rows can happily process partitions many times larger than a stage doing JSON parsing and a window function over wide nested records. Any single recommended size is implicitly assuming one of those and will be wrong for the other.
The metric that should set the number is spill. Zero spill and tasks comfortably longer than a few seconds means the size is fine. Spill means partitions are too big for the memory each task has. Median task durations in the tens of milliseconds means they are too small and you are paying scheduling overhead.
Case A — spill-dominated stage.
Before: stage 12, 200 tasks, median task input 7.8 GB, max ≈ median, disk spill 9 TB, duration 61 min.
Evidence: max ≈ median rules out skew. Enormous per-task input plus multi-TB spill says every partition exceeds execution memory.
Change: raise the initial shuffle partition count so partitions start small enough, and lower advisoryPartitionSizeInBytes so AQE does not coalesce them back into the same problem. One variable at a time: partition count first.
After: 3,200 tasks, median input 490 MB, spill 0, duration 19 min, executor-hours down 62%. The proof is that spill went to zero — not that runtime fell.
Case B — over-partitioned stage.
Before: stage 7, 240,000 tasks, median duration 40 ms, total duration 14 min, driver CPU pinned.
Evidence: tasks shorter than their own scheduling overhead. The driver is the bottleneck, not the executors.
Change: ensure AQE coalescing is enabled and raise advisoryPartitionSizeInBytes.
After: 4,100 tasks, median duration 2.1 s, total duration 4 min, driver CPU normal.
Case C — shuffle that should not exist.
Before: a 2 TB fact joined to an 80 MB dimension via SortMergeJoin; shuffle write 2.1 TB.
Evidence: the SQL tab shows the build side at 80 MB but the plan chose sort-merge — statistics are missing, so the estimate was far above reality.
Change: ANALYZE TABLE dim COMPUTE STATISTICS, re-run, confirm the plan converts to BroadcastHashJoin.
After: shuffle write 12 GB, runtime down 71%. No tuning knob was touched. The best shuffle optimization is the shuffle you delete.
"What should spark.sql.shuffle.partitions be set to?"
- Weak
- "200 is the default; I usually set it to 2000."
- Good
- "It depends on data size — roughly enough partitions that each one is a few hundred megabytes, and at least as many as you have cores."
- Senior
- "It's the wrong question in a modern Spark, and I'd say why. With AQE on — the default since 3.2 — that setting is the initial partition count, and AQE coalesces down to whatever
advisoryPartitionSizeInBytestargets, using the real shuffle sizes it measured. So the practical rule is: set it high enough that no single partition is oversized, then tune the advisory size, which is a size target rather than a count. The asymmetry is why — AQE coalescing merges small partitions, and AQE skew handling can split a skewed partition when it clears both the relative-factor and absolute-byte conditions, but neither rescues a uniformly under-partitioned stage where every partition is simply too big. What determines the right size is the evidence, not a number: if there's disk spill, partitions are too big for the memory each task actually has, which is executor memory divided by cores. If median task duration is in the tens of milliseconds, they're too small and scheduling overhead dominates. And I'd check whether the shuffle should exist at all before sizing it — a stale-statistics sort-merge join that should have been a broadcast is a much bigger win than any partition count."
Join engineering.
The join is where Spark makes its biggest decision and where you make your biggest mistakes. A single wrong strategy turns a four-minute query into a two-hour one, and a single wrong cardinality assumption turns a correct query into an output ten times larger than it should be — which then makes everything downstream slow too.
BroadcastHashJoin — fast, and the one that bites the driver
The small side is collected to the driver, serialized, and shipped to every executor, where it becomes a hash map. The large side is then joined in place with no shuffle at all. When it applies, nothing beats it.
- Good when the build side is genuinely small — small enough to sit in the driver and in every executor simultaneously.
- Threshold:
spark.sql.autoBroadcastJoinThresholdApache default 10 MB. Note this is compared against an estimate, and the estimate is of the size in Spark's internal representation, not the compressed file size on disk — a 10 MB Parquet file can expand well beyond 10 MB in memory. - AQE can promote a join to broadcast at runtime once it measures the real shuffle output, using
spark.sql.adaptive.autoBroadcastJoinThreshold. This is why the final plan can beat the static one. - Risks: the driver must hold the whole build side (Part X);
spark.sql.broadcastTimeoutApache default 300s will fail the job if collecting takes too long; and a/*+ BROADCAST(t) */hint overrides the size check, so a hinted table that quietly grows to 4 GB will take the driver down with it.
A hint tells Spark "trust me, this is small." Nothing re-checks it next quarter. Every broadcast hint in a long-lived pipeline should be paired with a size assertion or an alert on the table's row count — otherwise you have written a time bomb with a very long fuse. See Part XXX.
SortMergeJoin and ShuffledHashJoin
SortMergeJoin shuffles both sides by the join key, sorts each partition, and merges. The sort is what makes it robust: it can spill to disk and keep going, so it degrades rather than fails. It is the correct choice for large-to-large equi-joins and the default fallback when nothing better applies.
ShuffledHashJoin also shuffles both sides, but builds a hash map from one side's partition instead of sorting. It avoids the sort cost, which matters when neither side is sorted and you do not need ordered output — but it requires that one side's partition fits in execution memory. Under skew, one partition will not, and hash joins handle that far less gracefully than sort-merge. Spark chooses between them using size estimates and configuration; if you are forcing one with a hint, be sure you know which failure mode you have accepted.
Cardinality — the mistake that is not about performance at all
The most damaging join bug in data engineering is not a slow join. It is a join that produces the wrong number of rows and nobody notices for a week.
The eight join failure modes
| Mode | Symptom | How to catch it |
|---|---|---|
| Duplicate keys in a dimension | Output rows a clean multiple of expected; downstream sums inflated | GROUP BY key HAVING COUNT(*) > 1 on every dimension you join, as a test |
| Missing predicate | CartesianProduct or BroadcastNestedLoopJoin in the plan | Read the physical plan. Those two node names are alarms, not information |
ON 1=1 | Same as above, usually written deliberately "temporarily" | Ban it in review. If you need a cross join, say CROSS JOIN so it is visible |
| Non-equi join | BETWEEN or range conditions producing nested loops | Add an equality component (a bucket, a date key) so a hash join becomes possible, then filter |
| Many-to-many | Output rows = product of duplicate counts on both sides | Deduplicate one side first, or aggregate to the grain you actually need |
| Wrong join order | Large intermediate results before a filter reduces them | Filter early; check whether the optimizer pushed predicates down (EXPLAIN FORMATTED) |
| Stale statistics | A tiny table joined by sort-merge; a huge table broadcast | ANALYZE TABLE … COMPUTE STATISTICS; check EXPLAIN COST |
| Over-eager hints | A broadcast hint on a table that grew; driver OOM | Assert size in the pipeline; alert on the table's row count |
-- Table-level stats: row count and total size. Enables size-based strategy choice.
ANALYZE TABLE dim_customer COMPUTE STATISTICS;
-- Column-level stats: distinct counts, min/max, null counts.
-- These are what let the optimizer estimate SELECTIVITY, not just size.
ANALYZE TABLE dim_customer COMPUTE STATISTICS FOR COLUMNS customer_id, region, segment;
-- See what the optimizer believes:
EXPLAIN COST SELECT ... ;
Cost-based optimization (spark.sql.cbo.enabled) and CBO join reordering (spark.sql.cbo.joinReorder.enabled) are disabled by default in Apache Spark and depend entirely on these statistics. Turning CBO on without statistics changes nothing useful; collecting statistics without CBO still improves broadcast decisions. Collect first, then decide.
Platform-dependent Some engines and table formats maintain statistics automatically on write. If yours does, the manual ANALYZE may be unnecessary — but verify, because "the platform handles it" is a belief that survives long after it stops being true.
"Your join output grew from 100 million rows to 500 million rows overnight. The code didn't change. What happened and how do you find it?"
- Weak
- "The source data must have grown 5×."
- Good
- "Probably duplicates in the dimension table — I'd check for duplicate keys."
- Senior
- "A clean 5× on unchanged code is almost never organic growth — organic growth isn't a round multiple. The two candidates are duplicate keys on the dimension side, or a filter that stopped applying, and I'd distinguish them in about two minutes. First, count input rows on both sides: if the fact table is flat and the dimension gained rows, it's the dimension. Then
GROUP BY join_key HAVING COUNT(*) > 1on the dimension — if the modal duplicate count is 5, that's the answer, and the likely cause is either an SCD2 table where the current-record filter was dropped, or a load that ran twice. I'd also check the physical plan against yesterday's, in case a predicate stopped being pushed down. The important part is that this is a correctness incident, not a performance incident: every downstream aggregate has been 5× too high since it started, so the response includes finding out what published on top of it. And the prevention is a uniqueness test on the dimension's key plus an alert on output-rows-per-input-row, because that ratio catches the whole class."
Driver bottlenecks.
Every Spark tuning conversation is about executors. Almost every catastrophic Spark failure is about the driver. The asymmetry is structural: you can lose an executor and Spark recovers. Lose the driver and the application dies, mid-write, with no recovery path.
The nine ways to overwhelm a driver
1 · collect() and collectAsList()
Every row of the DataFrame is serialized on the executors, sent to the driver, and materialised as objects in the driver heap. A 5 GB DataFrame does not become 5 GB of driver memory — deserialised into JVM objects it is typically several times larger.
What to do instead: write the result to storage; aggregate before collecting; use limit(n).collect() when you genuinely want a sample; use foreachPartition when you need to push rows somewhere external.
2 · toPandas()
The PySpark special case, and worse than collect(): the data lands in the driver's JVM and is then converted into the driver's Python process. You need room for both representations at once. Arrow-based conversion makes the transfer far more efficient availability and defaults are version-dependent — check spark.sql.execution.arrow.pyspark.enabled in your Environment tab, but efficiency is not capacity: an efficient transfer of 40 GB into a driver with 8 GB still fails.
What to do instead: aggregate to the size a single machine should hold before converting. If the answer is "but I need all the rows in pandas", the pipeline design is wrong, not the memory setting.
3 · Huge broadcasts
The build side is collected to the driver first. A broadcast join is therefore a collect() that you did not write. Raising spark.sql.autoBroadcastJoinThreshold to "make more joins fast" is a direct increase in driver memory pressure, applied to every join in every query.
4 · Millions of tasks
The driver holds metadata for every task in every active stage, plus accumulator updates from each. At tens of thousands of tasks this is invisible. At millions it is a heap problem and a CPU problem simultaneously — the scheduler thread becomes the bottleneck and executors sit idle waiting to be given work.
The tell: low executor CPU, high driver CPU, and a stage whose task count is in the millions. Fix the partition sizing (Parts VIII, XII), not the driver memory.
5 · Metadata and file listing
Before a scan can run, Spark must know which files exist. On object storage, listing a table with a million files means a very large number of API calls. Spark parallelises this — see spark.sql.sources.parallelPartitionDiscovery.threshold and .parallelism — but a sufficiently pathological layout still puts minutes of latency in front of your job, all of it on the driver, all of it before a single task runs.
The tell: a long gap between "job submitted" and the first task starting, with no stage active. Part XII.
6 · Excessive result serialization
Even without collect(), every task returns a result object to the driver. Tasks that return large accumulator values, large metrics, or large partial results add up. Watch Result Serialization Time and Getting Result Time in the stage metrics.
7 · spark.driver.maxResultSize
Apache default 1g. This limit exists to fail your job cleanly instead of killing the driver. Raising it is right only when you have deliberately decided the driver should hold that much and have sized spark.driver.memory to match — with room for the deserialised form, not just the serialized bytes. Setting it to 0 (unlimited) is removing the airbag.
8 · Scheduler overhead from too many small jobs
A Python loop that calls an action per iteration produces one job per iteration. A thousand iterations is a thousand jobs, each with plan compilation, scheduling and teardown. The executors are barely used; the driver is at 100%.
The tell: the Jobs tab shows hundreds or thousands of tiny jobs. The fix: express the loop as data — union the inputs, add a column for the iteration variable, and run one job.
9 · Millions of small files on write
The write path commits files through the driver. Producing a million output files means a million commit operations to coordinate, and on object storage the commit protocol itself can take longer than the computation. Part XII.
Three signals, all cheap:
- Executors idle while the job is "running." Executors tab shows active tasks well below total slots and nothing pending-blocked.
- Driver CPU pinned. If you have host metrics, look at the driver process specifically.
- Long gaps between stages in the Jobs timeline with no stage active — plan compilation, listing, or commit.
If all three are present, no amount of executor tuning will help. Fix task count, file count, or what you are returning.
"A job with 500 executors is running at 10% cluster CPU and taking hours. Executors are alive but mostly idle. What's your hypothesis?"
- Weak
- "Add more executors so it finishes faster."
- Good
- "Something is bottlenecked outside the executors — maybe the driver, maybe I/O."
- Senior
- "Idle executors mean work isn't reaching them, so I'd stop thinking about executor capacity entirely. Three candidates, and they're distinguishable. One: the driver is the bottleneck — either it's scheduling millions of tiny tasks, or it's listing an enormous number of files before any stage starts, or the code is submitting thousands of small jobs in a loop. I'd check the Jobs tab for job count and the stage task counts, and look for gaps in the timeline where no stage is active. Two: it's I/O-bound — tasks are running but spending their time waiting on object storage, which shows up as low CPU time relative to task duration. Three: it's a dependency stall — one long-running task or a broadcast being built while everything else waits. Adding executors helps none of these and makes the first one measurably worse, because more executors means more tasks for the same driver to track."
CPU bottlenecks.
When a task is slow and the data is normal-sized and there is no spill and no fetch wait, you are simply computing too much per row. This is the most under-diagnosed category, because the UI has no "CPU" column — you infer it from what is absent.
Where the CPU actually goes
| Source | Why it costs | What to try first |
|---|---|---|
| Row-at-a-time Python UDF | Serialize each row out of the JVM, run Python, serialize back. Also a barrier: the optimizer cannot push filters through it | Replace with built-in expressions. If impossible, convert to a pandas/Arrow UDF. If still impossible, filter before the UDF so it sees fewer rows |
| Scalar JVM UDF | No serialization cost, but still opaque to the optimizer — no pushdown, no constant folding, no null-handling shortcuts | Prefer built-ins. A UDF is fine when the logic genuinely has no SQL equivalent |
| pandas / Arrow UDF | Far cheaper transfer, but batch size drives memory: a large batch × many concurrent workers is a container-kill risk | Tune the batch size deliberately; watch Python worker memory (Part V) |
| Regex | Backtracking patterns are superlinear. A bad pattern over a billion rows is an outage | Anchor patterns; prefer like/startswith/contains when they suffice; pre-filter before matching |
| JSON parsing | Parsing a string column per row is expensive and often repeated for each field extracted | Parse once into a struct with an explicit schema, then select fields — never call a JSON extract N times on the same column |
| Compression / decompression | Heavier codecs trade CPU for I/O. On a CPU-bound stage that trade is backwards | Match the codec to the bottleneck: cheaper codec when CPU-bound, denser when I/O- or network-bound |
| Encryption | Client-side or in-transit encryption is pure CPU on top of everything else | Usually non-negotiable — but account for it when sizing, and don't diagnose it as "Spark is slow" |
| Expensive window functions | Each distinct window specification implies a shuffle and a sort; unbounded frames can be O(n²)-ish per partition | Reuse one window spec across several expressions; bound frames; check whether an aggregate + join is cheaper |
| Repeated expressions | The same subexpression computed several times per row | Compute once in a subquery or CTE and reference it. Verify in the plan that it was not re-inlined |
| Object serialization | Encoding and decoding between internal formats and JVM objects | Stay in the DataFrame/SQL API; typed Dataset lambdas force object materialisation |
Catalyst fuses the operators inside a stage into a single generated loop, so a scan → filter → project → partial aggregate runs as one tight piece of code over each row, with no virtual calls and no intermediate objects. This is why native SQL is fast and why the boundary matters so much.
Certain operators break the fusion — a Python UDF is the obvious one, and it shows up in the plan as a separate BatchEvalPython/ArrowEvalPython node sitting between fused regions. In EXPLAIN FORMATTED output, fused regions are marked as whole-stage-codegen blocks; nodes outside them are your boundaries. Reading which operators fell outside is the fastest way to see where the per-row cost went.
- Task duration is uniform (max ≈ median) — so it is not skew.
- Spill is zero — so it is not memory.
- Shuffle read blocked time is near zero — so it is not the network.
- Input bytes per task are modest, but duration is long — so it is not I/O volume.
- Host CPU is high, if you have host metrics. This is the confirmation.
Then look at the plan for the operator that is doing per-row work: a UDF node, a regex, a JSON function, a window. If you can, bisect — run the same stage with the suspect expression removed and compare.
"Someone replaced a SQL CASE expression with a Python UDF because it was 'more readable.' Runtime went from 12 minutes to 3 hours. Explain precisely why."
- Weak
- "Python is slower than Scala."
- Good
- "Python UDFs serialize every row out to a Python process, which is much slower than a native expression."
- Senior
- "Two separate costs, and the second one is usually bigger than people expect. The first is mechanical: a row-at-a-time Python UDF moves every row out of the JVM into a Python worker process and back, so you pay serialization per row and you lose whole-stage code generation for that part of the plan — the fused loop is cut into pieces around the UDF node. The second is optimizer-level: the UDF is a black box, so Catalyst can't push a filter through it, can't eliminate it for rows that don't matter, and can't reorder around it. If that
CASEwas previously letting a predicate get pushed down into the scan, replacing it may also have turned a pruned read into a full scan — which is a much larger regression than the serialization. I'd confirm from the plan whetherPushedFilterschanged, and I'd fix it by putting the logic back in SQL. If the logic genuinely can't be expressed natively, a pandas UDF fixes the transfer cost but not the optimizer barrier, so I'd also make sure the filtering happens before it."
Storage and file engineering.
"Small files are bad" is the one piece of Spark folklore that is nearly always true and almost never explained. The explanation matters, because the same reasoning tells you when large files are also bad, and what to do about the layout you actually have.
How a columnar read actually saves you money
Parquet and ORC store data column by column, in row groups (Parquet) or stripes (ORC), each carrying statistics — min, max, null count — for each column. Three separate optimizations ride on that structure, and they are frequently confused:
| Optimization | What it skips | What enables it | How to verify |
|---|---|---|---|
| Partition pruning | Entire directories | The table is physically partitioned by the filtered column, and the filter is on the bare column | PartitionFilters populated in the scan node; "number of partitions read" metric |
| Predicate pushdown / data skipping | Row groups within a file | The predicate can be evaluated against row-group statistics | PushedFilters populated; bytes read far below file size |
| Column pruning (projection) | Columns you never reference | You did not write SELECT * | ReadSchema in the scan node lists only the columns you need |
Wrapping the partition column in a function, or comparing it to a different type. WHERE date_format(ds,'yyyy-MM') = '2026-08' or WHERE ds = 20260826 (integer against a string partition column) can stop Spark using the predicate to choose directories, leaving it to be evaluated after reading.
Can, not always will. Some coercions and some transforms are handled — it depends on the expression, the data source, the table format, and the version. Treat function-wrapping and type mismatch as things that weaken or prevent pruning, then check rather than assume: read PartitionFilters in the scan node and the "number of partitions read" metric. If PartitionFilters is empty and input bytes equal the whole table, pruning is gone. That check takes ten seconds and is the handbook's own evidence-first rule applied to its own advice.
When it does bite, it is a one-character bug with a four-hour price tag.
The read-side settings that actually matter
| Setting | Apache default | What it does | When to move it |
|---|---|---|---|
spark.sql.files.maxPartitionBytes | 128 MB | Target bytes per read partition when splitting files | Lower it when per-row work is expensive and you want more parallelism from the same bytes; raise it when you have far more tasks than useful work |
spark.sql.files.openCostInBytes | 4 MB | The estimated cost of opening a file, expressed in bytes, used when packing small files into partitions | Raising it makes Spark pack more small files per task — useful on high-latency object storage. It is a modelling parameter, not a limit |
spark.sql.sources.parallelPartitionDiscovery.threshold / .parallelism | 32 / 10000 | When and how widely to distribute partition-directory listing instead of doing it on the driver | Tables with very many partitions where the driver spends minutes listing |
spark.sql.files.minPartitionNum / .maxPartitionNum | unset | Floor/ceiling on the number of read partitions availability varies by version | When byte-based splitting alone gives you too few or absurdly many tasks |
Three properties matter for debugging. Listing is an API call, not a directory read — cost scales with the number of objects and prefixes, and it is often the invisible minutes at the start of a job. Every open has latency measured in milliseconds, which is nothing for one file and everything for fifty thousand. Throughput is often per-prefix, so a layout that concentrates reads on one prefix can throttle even when the total volume is modest Platform-dependent.
Data locality, the classic Spark concept, is largely moot on object storage — there is no "local" copy to schedule near. Seeing ANY locality on such a cluster is normal and is not a finding. On HDFS-style storage it still matters, and mostly-ANY locality there is a finding.
Compaction after the fact is a treatment. The cure is writing fewer, bigger files:
- Control output partition count before the write. The number of files a write produces is the number of partitions at that point (times the number of storage partitions written into). If you have 4,000 shuffle partitions writing into 200 date partitions, you can produce 800,000 files.
- Use
REBALANCESpark 3.3+ before writing: it lets AQE choose partition boundaries targeting an even output size, including splitting skewed ones. It is usually a better instinct than a hard-codedrepartition(n). - Be careful with
coalesce(). It avoids a shuffle but propagates upstream — coalescing to 10 partitions before a write can make the entire preceding computation run with 10-way parallelism. Part XIII. - Run compaction as a maintenance job for tables that are written incrementally many times a day. Modern table formats provide this as a first-class operation Platform-dependent.
"A table has 150 GB in 50,000 files. Reading it takes 25 minutes; the same data in 300 files takes 4. Where does the 21 minutes go?"
- Weak
- "Small files are slow — Spark has to open more of them."
- Good
- "Mostly file-open overhead and metadata reads — 50,000 opens with object-store latency each, plus a lot of tiny tasks."
- Senior
- "It's four separate costs and I'd expect them in roughly this order. First, listing: the driver has to enumerate 50,000 objects before any task starts, and that's serial-ish latency you can watch as a gap before the first stage. Second, per-file open and footer read: Parquet metadata cost scales with file count, not data size, so you pay 50,000 footer reads for the same 150 GB. Third, compression and encoding efficiency: 3 MB files have tiny row groups, so dictionary encoding and run-length encoding barely work and you physically transfer more bytes. Fourth, scheduling: with file-count-driven partitioning you get thousands of tasks each doing milliseconds of work, so per-task overhead becomes a real fraction, and the driver is tracking all of it. There's a fifth, subtler one — per-file statistics over 3 MB are too coarse-grained to skip anything, so predicate pushdown stops helping. The fix is compaction plus fixing whatever writer produces that layout, and I'd raise
openCostInBytesas an interim measure so Spark packs more small files into each task."
Partitioning — three different things with one name.
More confusion is caused by this single word than by any other in Spark. Three unrelated concepts share it, and engineers routinely apply the fix for one to a problem in another.
Choosing a storage partition key
- Partition on what you filter on, almost always a date or datetime bucket. If nobody filters on it, partitioning by it only creates directories.
- Watch cardinality. Partitioning by
customer_idin a system with 4 million customers creates 4 million directories, most containing one tiny file. Listing alone will dominate every query. This is the single most common irreversible modelling mistake. - Hour-level partitioning is right when queries genuinely filter by hour and each hour holds a substantial amount of data. When each hour holds 8 MB, you have built a small-file factory with extra steps.
- Under-partitioned is a real failure too: a table with no partitioning at all forces a full scan for every query, regardless of how selective the filter is.
- Modern table formats offer alternatives — hidden partitioning, transforms, clustering/sorting — which decouple the physical layout from the literal column values Platform-dependent. If you have them, they usually beat hand-rolled directory schemes.
repartition, repartitionByRange, coalesce, REBALANCE
| Operation | Shuffles? | What it gives you | The trap |
|---|---|---|---|
repartition(n) | Yes, full | Exactly n partitions, round-robin distributed — evenly sized | Costs a full shuffle. Doing it "to be safe" before a write is a common waste |
repartition(n, col) | Yes, full | n partitions hash-partitioned by col — co-locates equal keys | Does not fix skew: a hot value still lands in one partition |
repartitionByRange(n, col) | Yes (plus sampling) | Range-partitioned, good for sorted output and range pruning | Uses sampling to pick boundaries, so partition sizes are approximate and slightly non-deterministic |
coalesce(n) | No | Merges partitions without moving data across the network — cheap | Propagates upstream. See below. Also produces unevenly sized partitions |
REBALANCE hint | Yes | Lets AQE pick boundaries to hit an even target size, splitting skewed partitions | Spark 3.3+; needs AQE enabled |
coalesce(10) before a write does not mean "compute normally, then merge into 10 files." Because coalesce avoids a shuffle, it cannot create a new stage boundary — so the reduced parallelism applies to the whole stage it sits in, propagating back up the chain of narrow dependencies. Your 2,000-way computation now runs 10-way.
The tell is a final stage with exactly 10 tasks, each running for an extremely long time, with the preceding wide operation nowhere to be seen. If you want the merge to happen only at the end, you need a shuffle boundary — repartition(10) or a REBALANCE hint — and you pay for it deliberately.
"You add .coalesce(10) before writing to reduce the number of output files. Runtime goes from 8 minutes to 90. Why?"
- Weak
- "Ten partitions isn't enough parallelism for writing that much data."
- Good
- "
coalescereduces parallelism for the whole stage, not just the write, so the computation is now running with 10 tasks." - Senior
- "
coalesceis deliberately shuffle-free, which means it can't introduce a stage boundary — so the reduced partition count propagates backwards through every narrow dependency in that stage. Whatever transformation chain feeds the write is now running 10-way instead of at full parallelism, and if there's any per-partition memory pressure you've also multiplied the data each task holds by two hundred, so you may now be spilling as well. I'd confirm it in the UI: the final stage will show exactly 10 very long tasks. The fix depends on intent — if I want fewer output files and I'm willing to pay a shuffle,repartition(10)creates a boundary so the upstream work stays parallel; if I want even output sizes without guessing a number, aREBALANCEhint lets AQE size them against the advisory target. And I'd question the premise: 10 files for a large write may itself be too few, since output file size is a band, not a minimum."
When the layout already did the shuffle for you.
The cheapest exchange is the one that never happens. If both sides of a join are already physically laid out so that matching keys live in corresponding partitions, Spark can join them in place — no repartition, no sort, no network transfer. This is the idea behind bucketing in the classic Hive-style world, and behind Storage Partition Join for DataSource V2 sources.
What has to be true for this to work
This is powerful and narrow. Every one of these conditions must hold, and the feature silently declines when they do not:
- The source must be a DataSource V2 connector that reports its partitioning to Spark. A plain directory of Parquet files does not — it has no mechanism to tell the planner "rows for key K are all in split N."
- Both sides must be partitioned on the join keys in a compatible way — the same transform on the same columns.
- The feature must be enabled. In Apache Spark the V2 bucketing/storage-partition-join family sits behind
spark.sql.sources.v2.bucketing.enabled, which is disabled by default, with additional switches for pushing partition values, allowing partially clustered distributions, and allowing join keys that are a subset of the partition keys. - Version matters. The capability was introduced in the 3.x line and extended in subsequent releases, including 4.x. Version-dependent — the exact set of supported cases (subset keys, partially clustered distribution, one-side-only compatibility) differs between releases. Check your version's SQL configuration documentation rather than assuming the most permissive behaviour.
- Your connector must implement it. Table-format support varies by format and by connector version Platform-dependent.
There is no flag that says "storage partition join used." You verify it structurally: run EXPLAIN FORMATTED (or read the SQL tab) and look for the absence of Exchange nodes beneath the join. If both scans feed the join directly, it worked. If you see exchanges, it did not — and the interesting question is which condition failed.
The most common reasons it silently does not apply: the config is off; the two sides use different partition transforms; the join key is a subset of the partition keys and the corresponding permissive option is disabled; or the connector does not report partitioning at all.
Hive-style bucketing is the same idea in the V1 world — write both tables bucketed by the join key into the same number of buckets, and Spark can join bucket-to-bucket without a shuffle. It works, and it has real drawbacks that are worth stating honestly: the bucket count is baked into the table and changing it means a rewrite; both sides must share it; incremental writes tend to produce many small files per bucket; and a mismatch silently reintroduces the shuffle you were trying to avoid. It is a strong optimization for stable, frequently-joined pairs of large tables, and a maintenance burden everywhere else.
Caching: the optimization that most often backfires.
cache() feels like free speed. It is not free: it takes memory away from execution, it fills the old generation with long-lived objects, and when it does not fit it silently evicts — at which point you pay the recomputation you were trying to avoid plus everything the cache cost you.
The mechanics
| API | What it does | Note |
|---|---|---|
df.cache() | Marks the DataFrame for caching at the default storage level | Lazy — nothing is cached until an action runs |
df.persist(level) | Same, with an explicit storage level (memory only, memory and disk, serialized variants, replicated) | Serialized levels cut memory and GC pressure at the cost of CPU on read |
df.unpersist() | Releases the blocks | The step everyone forgets. In a long session, forgotten caches accumulate silently |
CACHE TABLE t / UNCACHE TABLE t | The SQL equivalent | CACHE TABLE is eager by default, unlike df.cache() — a useful difference to know |
- An expensive intermediate reused several times — the classic case. A heavy join or aggregation feeding three downstream branches.
- Iterative algorithms that walk the same dataset repeatedly.
- Breaking a long lineage before an action that would otherwise recompute a deep chain on retry.
- Interactive exploration where a human is going to query the same intermediate ten times in a row.
In every one of those, project first: cache the narrow, filtered result you will actually use, not the wide source. Caching 60 columns to read 6 wastes memory by an order of magnitude.
- Data used once. Pure overhead: you pay to write blocks nobody reads twice.
- Datasets far larger than available storage memory. Blocks are evicted, and evicted blocks are recomputed on access — you get the recomputation you feared plus the memory pressure you added.
- Jobs that already spill. You are taking memory from execution to give it to storage in a job that is short of execution memory.
- Long-lived sessions with forgotten caches. The classic notebook failure: twelve cached DataFrames from earlier experiments, none unpersisted, and a mysteriously degrading cluster.
- "Caching to make it faster" without measuring. Cache is an intervention with a cost. It belongs in the scientific loop of Part XXIV like any other change.
"An engineer adds .cache() to a DataFrame and the job gets 30% slower. How is that possible?"
- Weak
- "They must have cached the wrong DataFrame."
- Good
- "Caching takes memory from execution, and if the dataset doesn't fit, blocks get evicted and recomputed anyway."
- Senior
- "Three costs, any of which can exceed the benefit. First, storage and execution share one memory pool, so pinning cache blocks reduces what joins and aggregations can use — if the job was near the edge, it now spills, and spill is a write and a read per byte. Second, cached blocks are long-lived by construction, so they sit in the old generation and every major collection has to scan them; a job that was at 5% GC can climb into the double digits. Third, if it doesn't fit, you get the worst of both worlds — evicted blocks are recomputed on access, so you paid the caching cost and still do the work. I'd check the Storage tab for the fraction actually cached and the size in memory, check whether spill appeared where there was none before, and check GC time. And I'd ask the diagnostic question first: is this dataset read more than once at all? A cache on a dataset used once is pure loss, and that's the most common version of this bug."
Dynamic allocation and cluster sizing.
More executors do not automatically make a job faster. They make it wider. Width helps only if there is work waiting for a slot — and if there is not, every additional executor is a line item on an invoice for idling.
The knobs, and what evidence should move each
| Setting | Apache default | Move it when the evidence says… |
|---|---|---|
spark.dynamicAllocation.enabled | false often true on managed platforms | Your workload has variable parallelism across stages and you can tolerate ramp-up latency |
spark.dynamicAllocation.minExecutors / maxExecutors / initialExecutors | 0 / infinity / = min | Raise initialExecutors when short jobs spend a large share of their life ramping; cap maxExecutors when cost matters more than the last few minutes |
spark.dynamicAllocation.schedulerBacklogTimeout | 1s | Rarely. Shortening it makes ramp-up more aggressive and more wasteful on bursty jobs |
spark.dynamicAllocation.executorIdleTimeout | 60s | Lower it when you see long idle tails costing money; raise it when executors are being released and immediately re-requested |
spark.dynamicAllocation.shuffleTracking.enabled | false | You want dynamic allocation without an external shuffle service — Spark then keeps executors that still hold needed shuffle data |
spark.executor.cores | 1 (varies by cluster manager) | The most underrated knob in Spark. Lowering it increases memory per task at zero cost. Raising it increases concurrency and Python worker count per container |
spark.executor.instances | 2 (when dynamic allocation is off) | Only after you know tasks are actually pending. Pending tasks justify more executors; idle slots never do |
Unified memory is per executor, shared by all its concurrent tasks. So per-task memory ≈ (usable heap × spark.memory.fraction) ÷ spark.executor.cores. Halving cores doubles per-task memory without buying a single gigabyte. That is why "reduce executor cores" appears so often in the OOM fix list of Part V — and it is why huge fat executors so reliably spill.
The counter-pressure: fewer cores per executor means more JVMs for the same total capacity, each with its own overhead, its own copy of every broadcast, and its own set of shuffle connections. There is a middle band, it is workload-dependent, and the way to find it is the experiment in Part XXIV — not a number from a conference talk.
Doubling the cluster rarely halves the runtime. Some fraction of a job is serial (planning, listing, commit), some is bounded by a single stage's parallelism, and some is bounded by skew — and none of that scales. Meanwhile the bill scales exactly linearly with executor-hours.
So the honest question is never "can we make it faster" but "what is the cheapest configuration that meets the SLA?" A job that finishes at 04:10 against an 06:00 SLA does not benefit from finishing at 03:50. Part XXIII turns this into numbers.
"You double the executor count and runtime improves by 8%. What does that tell you?"
- Weak
- "We need to double it again."
- Good
- "The job isn't parallelism-bound — something else is the bottleneck, maybe skew or I/O."
- Senior
- "It tells me the job is not limited by available slots, and it tells me I just doubled the cost for 8%. The candidates: a single stage where the wall clock is set by one enormous task, so extra slots have nothing to do; a serial phase — file listing, plan compilation, the write commit — that doesn't parallelise at all; a downstream bottleneck like object-store throughput on one prefix; or the driver being the constraint, in which case more executors actively hurt. I'd confirm by looking at slot utilisation over time in the Executors tab: if a large fraction of slots are idle during the slow stage, adding capacity was never going to help, and the right response is to revert the change and go find what the stage is actually waiting on. I'd also record the executor-hours for both runs, because 'improved 8%' and 'cost 92% more' are the same sentence."
Stragglers and speculative execution.
Two completely different problems produce an identical-looking task table: one task running far longer than the rest. Treating one as the other is how engineers spend a week salting a key that was never skewed.
Speculative execution — what it is and what it cannot do
When enabled, Spark watches for tasks running much longer than their peers and launches duplicate copies on other executors. Whichever finishes first wins; the other is killed. It is a hedge against a slow host.
| Setting | Apache default | Meaning |
|---|---|---|
spark.speculation | false frequently enabled by managed platforms | Master switch |
spark.speculation.interval | 100ms | How often Spark checks for tasks to speculate |
spark.speculation.multiplier | 1.5 | A task is speculatable if it exceeds this multiple of the comparison duration |
spark.speculation.quantile | 0.75 | Fraction of tasks in the stage that must complete before speculation may start |
Against true skew, speculation duplicates the pathological partition onto another executor, where it takes just as long, while consuming a slot that could have run real work. You have added cost and removed capacity for zero benefit.
It also has correctness implications for anything non-idempotent. If a task writes to an external system, calls an API, or increments a counter, running it twice may do the thing twice. Speculation is safe for pure Spark computation and for commit protocols designed for it; it is a hazard for side-effecting foreachPartition code. Know which you have before enabling it.
Sort the task table by duration, take the slowest ten, and look at the executor id / host column. If the slow tasks are spread across many executors, it is not a host problem. If seven of ten share one executor, you have found it — and the next step is that host's metrics and that executor's GC time. A host that produces slow tasks across multiple different stages is conclusive.
"5,000 tasks. 4,998 finish in 45 seconds. Two run for 40 minutes. What do you investigate?"
- Weak
- "Increase executors so the slow ones have more resources."
- Good
- "Check the slow tasks — probably data skew. I'd look at the join key distribution."
- Senior
- "First I'd decide whether it's skew or a straggler, because the fixes are unrelated and the discriminator is one column. I open the task table, find those two tasks, and compare their input and shuffle-read bytes to the median. If they read a hundred times more data, it's skew: duration scales with work, and I go find the hot key with a
GROUP BY … ORDER BY COUNT(*) DESC, check for sentinels like -1 and NULL, see whether AQE skew handling engaged, and then choose between filtering invalid keys, isolating hot keys, or salting. If they read a normal amount and simply took longer, it's environmental: I check whether both tasks ran on the same executor or host, look at that executor's GC time, and check host disk and network metrics — a degraded disk or a throttled instance produces exactly this. Speculative execution is the right hedge for the second case and useless for the first, which is a good way to state the difference. And the reason this matters is economic: 4,998 slots sat idle for 39 minutes waiting for two tasks, so the cluster was doing almost nothing while being fully billed."
Network and shuffle failures.
Increasing the timeout can be like giving a sick patient a larger waiting room. They will wait longer. They will not get better.
Every network-shaped failure in Spark has the same tempting fix — raise a timeout, add a retry — and the same real answer: find out what stopped responding, and why.
The failure family
| Symptom | What it usually is | What it occasionally is |
|---|---|---|
FetchFailedException | The executor holding the shuffle blocks died | Genuine packet loss; an overloaded external shuffle service; blocks too large to transfer in time |
| Executor disappearance | Memory kill, preemption or node loss | Network partition — the executor is alive but unreachable |
| Connection reset | The other end went away mid-transfer | A middlebox, security group change, or connection-limit exhaustion |
| RPC / network timeout | The peer was too busy to answer (usually GC) | Real latency on a stretched or cross-zone network |
| Heartbeat timeout | Long GC pause or CPU starvation on the executor | Driver too busy to process heartbeats — a driver-side problem wearing an executor-side symptom |
| Large remote blocks | Skew — one partition's block is enormous | An intentionally huge advisory partition size |
| Retry storm | A cascade: one death causes fetch failures, which cause stage re-runs, which cause more pressure | Rarely anything else. Once you see repeated stage attempts, stop tuning and find the first death |
| Decommissioning interactions | Spot/preemptible reclamation removing executors that still hold shuffle output | Autoscaling releasing executors too eagerly without shuffle tracking |
Shuffle output is not replicated. It lives on the local disk of the executor that produced it. When that executor is gone, the data is gone, and the only way to get it back is to re-run the map tasks that produced it. Spark does this automatically — which is excellent engineering and financially brutal, because a fetch failure late in a large job can trigger re-execution of an hour of upstream work.
Then the re-execution puts the same pressure on the same cluster, and if the original cause was memory or disk, it happens again. That is the retry storm, and it is why a single under-provisioned executor can turn a 40-minute job into a four-hour one that eventually fails anyway.
This also explains why an external shuffle service (or push-based shuffle where available) changes the economics so much: it decouples shuffle data from the executor's lifetime, so losing an executor no longer loses its map output Platform-dependent — availability and defaults vary by cluster manager and vendor.
The settings, and the order to think about them
| Setting | Apache default | What it really controls | Legitimate reason to change it |
|---|---|---|---|
spark.network.timeout | 120s | Default timeout for most network interactions, including heartbeat detection | A genuinely high-latency environment, or a known long pause you are actively working to remove |
spark.executor.heartbeatInterval | 10s | How often executors report liveness and metrics | Very rarely. Must stay significantly below the network timeout |
spark.shuffle.io.maxRetries | 3 | Retries for a failed block fetch | Transient, genuinely recoverable network conditions |
spark.shuffle.io.retryWait | 5s | Wait between fetch retries | As above — and remember retries × wait is added latency on every failure |
spark.reducer.maxSizeInFlight | 48m | How much shuffle data a reducer requests concurrently | Lower it to relieve memory pressure on reducers; raise it on fast networks with spare memory |
spark.task.maxFailures | 4 | Task attempts before the stage is failed | Almost never. Raising it hides a recurring failure and pays for it four more times |
spark.stage.maxConsecutiveAttempts | 4 | Stage retries after fetch failures before giving up | Almost never — this is the retry-storm ceiling, and raising it makes storms longer |
- Did an executor die? Executors tab, filter by dead, match timestamps. This resolves the majority of cases immediately.
- Why did it die? Its own log, its exit code, the cluster-manager events. Memory kill, preemption, disk, node loss.
- Are shuffle blocks pathologically large? Max shuffle read vs median. If yes, this is skew wearing a network costume.
- Is one host implicated repeatedly? If so, the fix is to stop scheduling on it, not to tune Spark.
- Are host network metrics actually saturated? Only now is "the network" a finding rather than a guess.
- Only then consider timeouts and retries — and treat them as a resilience decision you are making consciously, with a note in the runbook saying why.
"A job fails after four hours with repeated stage retries and fetch failures. It normally takes 50 minutes. Where do you start?"
- Weak
- "Increase the number of stage attempts so it can recover."
- Good
- "Repeated stage retries mean map output keeps being lost — I'd look for executors dying."
- Senior
- "Four hours for a fifty-minute job with repeated stage attempts is a retry storm, and the important insight is that everything after the first failure is a consequence — so I'd go find the first one and ignore the rest. Shuffle output isn't replicated; it lives on the producing executor's local disk. So each time an executor is lost, Spark must re-run the map tasks that produced its blocks, and if the underlying cause is still present, the re-run kills another executor and the cycle compounds. I'd sort the Executors tab by removal time, take the earliest death, and read that executor's log and exit code: 137 means a memory kill, a disk-full message means local scratch, a clean SIGTERM means preemption or decommissioning. Whatever it is, that's the fix. Raising stage attempts would make the storm last longer and cost more before failing. If the root cause is spot reclamation I'd also ask whether an external shuffle service or shuffle tracking is available, because that decouples shuffle data from executor lifetime and removes the whole cascade."
Local disk bottlenecks.
Spark needs local scratch space — configured via spark.local.dir (or supplied by the cluster manager) — for two things: shuffle files, and spill. Both are invisible in most monitoring, and both can take an executor down.
Symptoms, in the order they usually appear
- Spill (disk) appears in stage metrics where it used to be zero.
- Task durations creep up as the stage progresses — later tasks contend with earlier tasks' spill files.
- Shuffle write time rises even though shuffle bytes are unchanged.
- Executors are lost with "no space left on device", or die silently after the volume fills.
- Fetch failures and stage retries begin — the loop above is now running.
- Stage metrics: Spill (memory) and Spill (disk). Both non-zero and large is the signature.
- Executor logs: search for
No space left on device,IOExceptionon a local path, or messages about spilling. - Host metrics: disk utilisation and I/O wait on the scratch volume — not the root volume, which is often a different device.
- Instance/volume type Platform-dependent: a node with fast local NVMe and one with a small network-attached volume have wildly different behaviour under the same Spark configuration. Two clusters that look identical on paper can differ by an order of magnitude here.
- Number of local dirs: spreading scratch across multiple physical devices increases aggregate throughput. One directory on one small volume is a common, invisible constraint.
- Eliminate the spill by making partitions smaller (Part VIII) or giving each task more memory by reducing executor cores (Part V). This ends the loop rather than accommodating it.
- Remove unnecessary shuffles — a broadcast join that should have happened, a redundant
repartition, a sort you did not need. - Provision appropriate scratch — enough space, fast enough devices, several of them. This is a legitimate fix, not a workaround, when the shuffle genuinely must exist.
- Reduce shuffle volume by projecting fewer columns and pre-aggregating before the exchange.
- Shorten executor lifetime pressure — if shuffle files accumulate across many stages, consider whether an external shuffle service or a different scale-down policy applies Platform-dependent.
SQL anti-patterns.
Fifteen patterns, each with the physical consequence that makes it expensive and the evidence that proves you fixed it. None of them are style opinions; every one changes the plan.
1 · SELECT *
SELECT *
FROM events e
JOIN users u ON e.user_id = u.user_id
WHERE e.ds = '2026-08-26';SELECT e.event_id, e.event_ts, e.event_type,
u.country, u.segment
FROM events e
JOIN users u ON e.user_id = u.user_id
WHERE e.ds = '2026-08-26';Why. Columnar formats let you read only the columns you name. SELECT * defeats projection pushdown, so you read every column off storage, carry every column through every exchange, and shuffle bytes you never look at. On a wide table this is frequently a 5–20× difference in bytes scanned and shuffled. It also makes the query fragile: a new upstream column silently changes your output schema.
Verify. ReadSchema in the scan node lists only your columns; input bytes and shuffle write both drop.
2 · Unnecessary DISTINCT
SELECT DISTINCT o.order_id, o.amount
FROM orders o
JOIN order_items i ON o.order_id = i.order_id;
-- DISTINCT added to "fix" duplicates from the joinSELECT o.order_id, o.amount
FROM orders o
WHERE EXISTS (SELECT 1 FROM order_items i
WHERE i.order_id = o.order_id);
-- semi-join: no fan-out, so no dedup neededWhy. DISTINCT forces a full shuffle and aggregation over every selected column. Worse, it is usually a bandage over a cardinality bug — the join fanned out and someone deduplicated the symptom. A semi-join expresses the actual intent ("orders that have items") and never multiplies rows in the first place.
Verify. The Exchange + HashAggregate pair for the dedup disappears from the plan; output row count is unchanged.
3 · Global ORDER BY
SELECT * FROM transactions
WHERE ds = '2026-08-26'
ORDER BY event_ts; -- 4 billion rows, globally ordered-- If you need a top-N:
SELECT * FROM transactions
WHERE ds = '2026-08-26'
ORDER BY event_ts DESC LIMIT 1000; -- optimizer can do a partial sort
-- If you need sorted FILES, not a sorted result set:
SELECT /*+ REPARTITION_BY_RANGE(200, event_ts) */ *
FROM transactions WHERE ds = '2026-08-26'
SORT BY event_ts; -- sorted within partitionsWhy. A total order requires a range-partitioning shuffle followed by a sort, and for a truly global order the final phase can serialise. ORDER BY with LIMIT is a different physical operation — the engine can keep only the top N per partition. And "the output files should be sorted" is SORT BY within partitions, which is cheap, not ORDER BY, which is not.
Verify. The plan shows TakeOrderedAndProject instead of a full Sort over a global exchange.
4 · Repeated scans of the same table
SELECT (SELECT COUNT(*) FROM events WHERE ds='2026-08-26' AND type='view') AS views,
(SELECT COUNT(*) FROM events WHERE ds='2026-08-26' AND type='click') AS clicks,
(SELECT COUNT(*) FROM events WHERE ds='2026-08-26' AND type='buy') AS buys;SELECT COUNT(*) FILTER (WHERE type='view') AS views,
COUNT(*) FILTER (WHERE type='click') AS clicks,
COUNT(*) FILTER (WHERE type='buy') AS buys
FROM events
WHERE ds = '2026-08-26'; -- one scan, three conditional aggregatesWhy. Three subqueries are three scans of the same partition. Conditional aggregation reads it once. The same principle applies to CTEs referenced multiple times — a CTE is not automatically materialised, so referencing it three times can mean computing it three times unless the optimizer reuses the exchange.
Verify. Count the Scan nodes in the plan; look for ReusedExchange where you expect reuse. Input bytes should fall by roughly the number of eliminated scans.
5 · Deeply nested subqueries
Why. Deep nesting is not inherently slow — Catalyst flattens much of it — but it makes plans enormous, hides where filters actually apply, and can produce plan-compilation times measured in minutes on the driver (Part X). The practical cost is diagnostic: nobody can read the plan to find the problem.
Better. Flatten into named CTEs at a consistent grain, each doing one thing. If a CTE is expensive and reused, materialise it deliberately rather than hoping for reuse.
Verify. Plan-compilation time (the gap before the first stage) and plan size both drop; the plan becomes readable, which is the point.
6 · Accidental CROSS JOIN
SELECT a.*, b.rate
FROM orders a, fx_rates b
WHERE a.currency = b.currency;
-- the "old style" comma join, one WHERE clause away from disaster:
-- delete that predicate and you have a cartesian productSELECT a.*, b.rate
FROM orders a
JOIN fx_rates b
ON a.currency = b.currency
AND a.rate_date = b.rate_date; -- explicit, complete, reviewableWhy. A missing predicate produces CartesianProduct or BroadcastNestedLoopJoin — output rows equal to the product of the inputs, or a comparison count equal to it. 1M × 50k is 50 billion comparisons. Explicit JOIN … ON makes an incomplete condition visible in review.
Verify. No CartesianProduct or BroadcastNestedLoopJoin node in the plan. Treat either as a build-breaking finding.
7 · Many-to-many joins
SELECT o.order_id, SUM(o.amount) AS revenue
FROM orders o
JOIN dim_customer c ON o.customer_id = c.customer_id -- SCD2: N rows/customer
GROUP BY o.order_id; -- revenue is now N× too highSELECT o.order_id, SUM(o.amount) AS revenue
FROM orders o
JOIN (SELECT customer_id, segment FROM dim_customer WHERE is_current) c
ON o.customer_id = c.customer_id
GROUP BY o.order_id; -- one row per customer, guaranteedWhy. This is Part IX's fan-out as a correctness bug: the aggregate is wrong, not just slow. Reducing the dimension to the grain you need before joining fixes both.
Verify. Output row count matches the fact table's row count; a uniqueness test on the dimension key passes.
8 · Function-wrapped partition keys
WHERE date_format(ds, 'yyyy-MM') = '2026-08'
WHERE CAST(ds AS DATE) >= current_date() - 7
WHERE ds = 20260826 -- int compared to a string partition columnWHERE ds BETWEEN '2026-08-01' AND '2026-08-31'
WHERE ds >= date_format(current_date() - 7, 'yyyy-MM-dd')
WHERE ds = '2026-08-26' -- bare column, matching typeWhy. A function wrapped around the partition column, or a type mismatch that makes the engine cast the column rather than the literal, can prevent or weaken directory pruning — the filter then gets evaluated after reading. Whether a given expression defeats pruning depends on the expression, the source and the version, so the rule is behavioural rather than absolute: compute the constant side, leave the column bare, and verify.
Verify. PartitionFilters is populated in the scan node; the "number of partitions read" metric matches your expectation; input bytes drop from table-size to partition-size.
9 · Missing filters
Why. The cheapest byte is the one you never read. A query with no date bound on a partitioned fact table scans the entire history — and the sad version of this bug is a dashboard that was correct on a six-month-old table and is now scanning four years.
Better. Bound every fact-table read by its partition column, and treat an unbounded fact scan as a review finding. Where the tool generating the SQL cannot be trusted, enforce it with a required-partition-filter setting if your table format supports one Platform-dependent.
Verify. Input bytes; partitions read.
10 · Filtering too late
WITH joined AS (
SELECT * FROM events e JOIN users u ON e.user_id = u.user_id
)
SELECT * FROM joined WHERE country = 'IN' AND ds = '2026-08-26';WITH e AS (SELECT * FROM events WHERE ds = '2026-08-26'),
u AS (SELECT * FROM users WHERE country = 'IN')
SELECT * FROM e JOIN u ON e.user_id = u.user_id;Why. Catalyst pushes predicates down aggressively and will often fix this for you — but not always: it cannot push through a Python UDF, through some window functions, through certain outer joins, or through anything non-deterministic. When it cannot, you have shuffled the entire join before discarding 98% of it. Writing it filtered costs nothing and removes the dependency on the optimizer's cooperation.
Verify. PushedFilters in the scan nodes; shuffle write bytes for the join stage.
11 · Unbounded explode()
SELECT user_id, explode(page_views) AS pv
FROM sessions; -- one session can carry 400,000 page views-- 1. know the distribution first
SELECT percentile_approx(size(page_views), array(0.5,0.95,0.99)) ,
max(size(page_views))
FROM sessions;
-- 2. then explode with the volume under control
SELECT user_id, pv
FROM sessions
LATERAL VIEW explode(slice(page_views, 1, 1000)) t AS pv
WHERE ds = '2026-08-26';Why. explode multiplies rows, and array-length distributions are almost always heavy-tailed. The result is skew created inside your query, downstream of any input-side balancing — AQE cannot help with a partition that became huge after the shuffle boundary. Aggregating before exploding, or bounding the array, or exploding after filtering, all attack it directly.
Verify. The Generate node's output-row metric versus its input rows; task duration distribution in the stage after it.
12 · Unnecessary repartition()
Why. A repartition is a full shuffle: serialize, write to disk, transfer, read, deserialize. "Repartitioning to be safe" before a write, or after a filter "to rebalance", pays that cost for nothing. With AQE coalescing enabled, the post-filter rebalance case is usually already handled.
Better. Remove it and measure. If you genuinely need even output sizes, use a REBALANCE hint, which lets AQE size partitions instead of you guessing a number.
Verify. One fewer Exchange in the plan; total shuffle bytes drop by the size of the removed exchange.
13 · Duplicate expressions
SELECT
get_json_object(payload,'$.user.country') AS country,
UPPER(get_json_object(payload,'$.user.country')) AS country_uc,
CASE WHEN get_json_object(payload,'$.user.country')='IN'
THEN 1 ELSE 0 END AS is_in
FROM events; -- the JSON is parsed three times per rowWITH parsed AS (
SELECT from_json(payload, 'user STRUCT<country:STRING>') AS j, *
FROM events
)
SELECT j.user.country AS country,
UPPER(j.user.country) AS country_uc,
CASE WHEN j.user.country='IN' THEN 1 ELSE 0 END AS is_in
FROM parsed; -- parsed once, with a schemaWhy. Common-subexpression elimination helps, but it is not guaranteed for every expression shape, and per-row JSON parsing is expensive enough that three times is three times. Parsing once into a typed struct also lets the engine prune fields.
Verify. Stage CPU time falls with unchanged input and output; the plan shows a single parse.
14 · Poorly designed window functions
SELECT user_id, event_ts,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_ts) AS rn,
SUM(amount) OVER (PARTITION BY country ORDER BY event_ts) AS running,
MAX(amount) OVER (PARTITION BY user_id ORDER BY amount) AS mx
FROM events; -- three DIFFERENT window specs → three shuffles + three sortsSELECT user_id, event_ts,
ROW_NUMBER() OVER w AS rn,
SUM(amount) OVER w AS running_user,
MAX(amount) OVER w AS mx
FROM events
WINDOW w AS (PARTITION BY user_id ORDER BY event_ts); -- one spec, one shuffleWhy. Each distinct window specification implies its own exchange and sort. Sharing one specification across several expressions collapses them into a single pass. Also watch the partition key: PARTITION BY country in a business concentrated in one country is skew by construction.
Verify. Count the Window and Exchange nodes in the plan — the goal is one of each where you previously had three.
15 · Unnecessary Python UDFs
@udf("string")
def clean_email(s):
return s.strip().lower() if s else None
df.withColumn("email", clean_email("email_raw"))from pyspark.sql import functions as F
df.withColumn("email", F.lower(F.trim(F.col("email_raw"))))
# native, fused into the generated stage code, and the optimizer
# can still push filters through itWhy. Part XI in one line: per-row serialization out of the JVM, plus an optimizer barrier that can silently cost you a pushed-down filter. The native version is also shorter.
Verify. No BatchEvalPython node in the plan; stage duration and CPU time fall; check that PushedFilters did not change for the worse.
"Give me three SQL changes that would speed up a slow Spark query, and tell me how you'd prove each one worked."
- Weak
- "Add more filters, avoid
SELECT *, and use broadcast joins." - Good
- "Project only needed columns to cut scan and shuffle bytes; make sure the partition filter is on a bare column so pruning works; and pre-aggregate before joining so we shuffle less."
- Senior
- "I'd order them by which of the three bills they cut, and name the metric that proves each. Cut bytes scanned: project explicit columns and keep the partition predicate on a bare, correctly-typed column — proof is
ReadSchema, a populatedPartitionFilters, and input bytes dropping from table size to partition size. Cut bytes shuffled: pre-aggregate before the join, or restore a broadcast that stale statistics turned into a sort-merge — proof is shuffle write bytes for that stage and the join node changing in the plan. Cut bytes written: make the job incremental so it touches one partition rather than rewriting history — proof is output bytes and file count. In every case I'd capture the before numbers first, change one thing, and compare, because the failure mode in this work is making three changes and being unable to attribute the improvement to any of them."
Data problems that look like Spark problems.
Sometimes Spark is not slow. Spark is faithfully processing ten times more data than yesterday.
A meaningful share of "Spark performance incidents" are data incidents with a Spark-shaped symptom. They are also the fastest to diagnose, if you look at input volume before you look at anything else — which is why "input bytes" is the second thing in the triage checklist of Part II.
The ten data events that page you as a Spark incident
| Event | Spark-side symptom | The one-line check |
|---|---|---|
| Duplicate dimension rows | Output rows a clean multiple; shuffle up; downstream sums wrong | GROUP BY key HAVING COUNT(*) > 1 |
| Unexpected NULL explosion | One partition enormous — all NULLs hash together | COUNT(*) FILTER (WHERE key IS NULL) as a share of total |
| Schema drift | AnalysisException, or silently wrong results when a type widened | Compare the source schema against the last known good |
| Record-size explosion | Bytes up sharply while rows are flat; spill appears | Average bytes per row, tracked over time |
| Bad delimiter / parser behaviour | Row counts wildly off; one column contains the whole line | Row count vs expected; distinct count of a column that should be low-cardinality |
| Nested JSON growth | CPU up, bytes up, rows flat — parsing more per record | Average payload length percentiles |
| Suddenly larger storage partitions | A few read tasks are enormous; skew before any shuffle exists | Bytes per partition directory, top 20 |
| Corrupted files | ParquetDecodingException or a task that fails only on retry-specific files | Identify the file from the trace; check the writer's history |
| Source reprocessing | Input doubles overnight with no code change | Input bytes vs the same weekday last week |
| Upstream duplicate load | Input exactly 2×; every downstream metric doubles | Distinct count of a natural key vs row count |
Emit five numbers from every pipeline run, into a table you can query: input rows, input bytes, output rows, output bytes, and the run's identity (pipeline, run id, application id). That is it. Those five turn "the job is slow" into "input is flat and output is 5× — this is a fan-out" in one query, forever, for every pipeline you own.
"A pipeline that ran in 40 minutes for a year suddenly takes 4 hours. No deploy, no config change, no cluster change. Your first three checks?"
- Weak
- "Check the Spark UI for a slow stage."
- Good
- "Compare input volume against previous runs first, then look at the slowest stage, then the plan."
- Senior
- "With nothing changed on our side, the change is in the data or in the environment, so I'd check those before opening the UI at all. One: input bytes and input rows versus the same weekday last week — a source that reprocessed history, or a duplicate load, explains everything instantly and is not a Spark problem. Two: output rows divided by input rows — if that ratio moved, a join fanned out, which means we also have a correctness incident and everything published since is wrong. Three: input file count and bytes-per-file — if someone changed the upstream writer, we may be reading the same data through fifty times more files, which is a pure overhead event. Only if all three are flat do I go to the Spark UI, and then I'd diff the physical plan against a previous run, because with no code change the remaining candidate is a plan change from stale or refreshed statistics. Cluster-side, I'd also confirm we actually got the executors we asked for — spot reclamation or a quota change can halve effective capacity silently."
Reading performance regressions.
Here is a real-shaped regression. Before you read on, decide what you think happened.
| Metric | Yesterday | Today | Change |
|---|---|---|---|
| Input | 2.1 TB | 2.2 TB | +5% |
| Runtime | 42 min | 126 min | ×3.0 |
| Shuffle | 3.4 TB | 14.8 TB | ×4.4 |
| Executor-hours | 160 | 520 | ×3.3 |
Input is essentially flat, so this is not a volume event. Shuffle grew 4.4× on flat input, which means the plan is moving far more data than it was — and a plan does not change by itself. Runtime and executor-hours moved with shuffle, not with input, which confirms the shuffle is the driver of the cost rather than a side effect.
That narrows it to a small set: (a) a join strategy flipped — most often a broadcast that stopped happening because statistics went stale or the build side crossed the threshold; (b) a filter stopped being pushed down, so more rows reach the exchange; (c) row multiplication upstream of the shuffle — a fan-out or an explode; (d) a partitioning/bucketing compatibility was lost, reintroducing an exchange that used to be absent.
The next action is not a config. It is diff the physical plans. One of those four will be visible as a changed node, and each has a different fix.
What is not a sensible next action: adding executors. Executor-hours already tripled; the job is not short of capacity, it is doing 4.4× the work.
The Spark Pipeline Health Scorecard
Every one of these should be captured per run and compared against a rolling baseline. The right-hand column is what an alert should actually fire on — a ratio or a deviation, never an absolute.
| Metric | Why it is on the card | Healthy pattern | Alert condition (ratio, not absolute) |
|---|---|---|---|
| Runtime | The SLA number | Stable ±15% | > 1.5× rolling median for the same weekday |
| Input bytes | Separates data events from code events | Tracks business growth | > 1.3× or < 0.7× of the same weekday last week |
| Input rows | Distinguishes wider records from more records | Tracks bytes | Bytes/rows ratio moves > 20% |
| Output rows | Correctness canary | Tracks input | Output ÷ input deviates from its own baseline |
| Output bytes / file count | Small-file and layout regressions | Stable bytes per file | File count > 2× with flat bytes |
| Shuffle read / write | The plan's fingerprint in bytes | Roughly proportional to input | Shuffle ÷ input moves > 50% |
| Spill (memory + disk) | The honest memory signal | Zero, or stable and small | Any appearance where there was none |
| GC time ÷ task time | Executor health | Low single digits | Doubling, or crossing your own established band |
| CPU time ÷ wall time | Are we computing or waiting? | Stable | Falls sharply → new I/O or lock wait |
| Task count | Partitioning and file-layout changes | Stable per unit of input | > 2× with flat input |
| Task p50 / p95 / max | Skew detection, automated | max ÷ p50 stable and modest | max ÷ p50 exceeds its own baseline |
| Executor-hours | The cost number | Tracks runtime × size | Rises while runtime falls or stays flat |
| Failed / retried tasks | Hidden instability | Zero | Any non-zero value, trended |
| Executor losses | The precursor to retry storms | Zero | Any, especially clustered in time |
| Cost per run / per TB | The number finance sees | Falling per TB over time | Cost per TB rising |
| SQL plan fingerprint | Catches plan flips with no code change | Identical between runs | Changed — investigate before the runtime does it for you |
You do not need a plan-diff product. Capture the physical plan as a string, strip the volatile parts (row-count estimates, file paths, generated ids), hash it, and store the hash with the run. When the hash changes and nobody deployed, you have found a plan flip before it becomes an incident. It is perhaps fifteen lines of code and it is the highest-leverage observability in this handbook after volume counters.
Cost optimization — fastest job ≠ best job.
Runtime is the metric everyone optimises because it is the one everyone can see. Cost is the metric the business feels. They are not the same axis, and the gap between them is where most Spark "wins" quietly evaporate.
| Version | Runtime | Executors | Executor-hours | Relative cost | Meets a 06:00 SLA? |
|---|---|---|---|---|---|
| A | 90 min | 100 | 150 | 1.0× | Yes |
| B | 50 min | 500 | 417 | 2.8× | Yes |
| C | 58 min | 180 | 174 | 1.16× | Yes |
Where the money actually goes
| Cost source | How it hides | How to find it |
|---|---|---|
| Idle executors | Held through a long tail while two skewed tasks finish | Slot utilisation over time; the gap between allocated and active executors |
| Oversized instances | A memory-shaped instance for a CPU-bound job, or vice versa | Compare peak memory used against provisioned; compare CPU utilisation |
| Cluster startup | Invisible in the Spark UI entirely | Orchestrator timestamps (Part III). On short jobs this can be a third of the bill |
| Excessive retries | Every re-executed stage is paid for twice | Stage attempt counts; failed task counts |
| Re-reading data | The same source scanned in three subqueries | Count of scan nodes; total input bytes vs table size |
| Shuffle | The most expensive operation, priced as "compute" | Shuffle bytes as a ratio to input bytes |
| Storage operations | Listing and per-request charges on small-file layouts | File counts; your storage provider's request metrics Platform-dependent |
| Over-provisioned SLA | A job that must finish by 06:00 tuned to finish at 03:00 | Ask the consumer when they actually need it. Frequently the cheapest optimization available |
Performance per dollar — TB processed per unit of spend. Track it per pipeline. It is the only fair way to compare a job that got 20% faster and 60% more expensive against one that got 5% slower and 40% cheaper.
Cost per TB processed — total run cost ÷ input TB. This is the number that should fall over time as you improve layout, pruning and join strategy. If your data volume grew 3× and cost grew 3×, you have not improved anything; you have scaled linearly, which is the baseline, not an achievement.
Both metrics have a property that makes them politically useful: they keep improving when you make the job cheaper, not just faster. That is the behaviour you want to reward.
"You cut a pipeline's runtime by 40%. Your manager asks whether it was worth it. What do you say?"
- Weak
- "Yes — it's 40% faster."
- Good
- "Depends on the cost. I'd check whether executor-hours went up, because a 40% speedup from tripling the cluster isn't a win."
- Senior
- "I'd answer with three numbers rather than one. Executor-hours before and after, because that's the cost; cost per TB processed, because that normalises for data growth and is the number that should trend down over the year; and whether the SLA was ever at risk, because if the job finished at 04:00 against an 06:00 deadline, the 40% bought nothing anyone can use. The version of this I'd actually be proud of is the one where runtime improved and executor-hours fell — that means I removed work rather than bought parallelism, which usually means a plan or layout fix rather than a sizing change. And I'd say what I'd do with the headroom: if we now finish 90 minutes early with no consumer waiting, the follow-up is to shrink the cluster until we're comfortably inside the SLA and bank the difference."
The scientific tuning method.
Everything in this handbook collapses without this section. Spark tuning is an experimental discipline conducted on a system with high variance, and the failure mode is not ignorance — it is uncontrolled experiments. Change six configs, rerun, note that it is faster, and you have learned nothing you can transfer, defend, or safely undo.
A worked experiment
Hypothesis
"Stage 12's runtime is dominated by disk spill, because each shuffle partition is far larger than the execution memory available per task."
Note the shape: it names a mechanism and it is falsifiable. "The job needs more memory" is neither.
Evidence supporting it
- Median task shuffle read: 7.8 GB; max ≈ median → not skew.
- Spill (disk) for the stage: 9 TB.
- Executor heap 16 GB, 5 cores → roughly 2 GB of unified memory per task. A 7.8 GB partition cannot fit; spill is inevitable, not incidental.
- Stage duration 61 min out of an 84-minute job → this stage is the job.
Prediction (write it down before running)
"If I roughly quadruple the number of shuffle partitions for this stage, median task input falls to about 2 GB, spill approaches zero, and stage duration falls by more than half. If spill does not fall, the hypothesis is wrong and the cost is elsewhere."
The single change
# ONE variable. Not memory. Not cores. Not the join strategy.
spark.sql.shuffle.partitions = 3200 # was 800
# and keep AQE from coalescing straight back to the original size:
spark.sql.adaptive.advisoryPartitionSizeInBytes = 128m
(Strictly, that is two settings — but they are one mechanism: the partition-size target. Changing the count without the advisory size would have let AQE undo the experiment, which is exactly the kind of interaction you must reason about before you run.)
Measure — the same metrics as the baseline, no others
| Metric | Baseline | After | Predicted? |
|---|---|---|---|
| Median task shuffle read | 7.8 GB | 1.9 GB | ✓ |
| Spill (disk) | 9 TB | 0 B | ✓ — the confirming metric |
| Stage 12 duration | 61 min | 17 min | ✓ |
| Job runtime | 84 min | 39 min | ✓ |
| Executor-hours | 224 | 104 | bonus: cheaper as well as faster |
| Task count (stage 12) | 800 | 3,180 | expected side effect |
| Output file count | 800 | 3,180 | watch this — a new small-file risk downstream |
Keep, and document
# stage 12 spilled 9 TB because each shuffle partition held ~7.8 GB against
# ~2 GB of per-task execution memory (16 GB heap / 5 cores × memory.fraction).
# Raising initial partitions to 3200 with a 128 MB advisory size removed the
# spill entirely: 61 min -> 17 min, 224 -> 104 executor-hours. Verified by
# spill going to zero, not by runtime alone.
# NOTE: this also multiplied output file count 4x — REBALANCE added before the
# write to keep file sizes sane. Revisit if input volume grows past ~4 TB.
Regression-test it
Add spill for stage 12 to the health scorecard with an alert on "any disk spill". The failure you just fixed is now a monitored condition rather than an oral tradition.
What this is NOT
"Job slow. Increase memory." — no bottleneck identified, no mechanism named, no prediction, no confirming metric, nothing learned, and a 2× cost increase that nobody will ever feel confident enough to reverse.
"You changed five Spark configs and the job got 30% faster. What's the problem?"
- Weak
- "Nothing — it's faster."
- Good
- "You don't know which change helped, so you can't undo the ones that didn't."
- Senior
- "Three problems, in increasing order of seriousness. The obvious one is attribution: with five variables you can't say which mattered, so you carry four unexplained settings forever and nobody dares remove them. The second is that some of those changes may have hurt, and the one big win is masking them — so the real available improvement might have been 50%, not 30%. The third is the one that bites later: a config chosen without a mechanism doesn't survive a change in the data. If I raised memory and the real problem was skew, the fix works until the hot key grows 20% and then fails at 3 a.m., and the runbook says 'increase memory' because that's what worked last time. The discipline that avoids all three is cheap: baseline, one variable, predict what should move, measure the metric that confirms the mechanism rather than just the runtime, then document why. And on Spark specifically I'd re-run before believing a small delta, because run-to-run variance from cluster state and upstream data is real."
The Spark troubleshooting matrix.
Thirty-four symptoms, each with the evidence that identifies it and the fix that usually makes it worse. Scroll horizontally; this table is meant to be printed and pinned.
| Symptom | Spark UI evidence | Logs | Likely cause | First investigation | Possible fix | Dangerous knee-jerk |
|---|---|---|---|---|---|---|
| Stuck at 99% | 1–3 tasks running, thousands complete; max ≫ median duration | Usually silent | Skew, or a straggler host | Compare slow task's shuffle read to median | Isolate/filter hot key; salt; AQE skew thresholds | Add executors — the idle 997 slots are already free |
| All tasks slow | max ≈ median, every task large; spill present | Spill messages | Under-partitioning, or expensive per-row work | Per-task input bytes; CPU time ÷ duration | Raise partition count; lower advisory size; remove UDF | Increase executor memory to absorb bigger partitions |
| High disk spill | Spill (disk) in GB/TB per task | No space left in bad cases | Partition larger than per-task execution memory | Heap ÷ cores × memory.fraction vs partition size | More partitions, or fewer cores per executor | Bigger local disks — treats the symptom, feeds the loop |
| High memory spill | Spill (memory) non-zero, disk spill low | Quiet | Execution memory tight but recoverable | Peak execution memory vs budget | Reduce cores per executor; smaller partitions | Ignore it — it is the early warning for the row above |
| Executor heap OOM | Failed tasks; stage retries | OutOfMemoryError: Java heap space | One task's working set exceeds heap share | Skew check first, always | Fix skew, or raise parallelism, or cut cores | Double executor memory before checking distribution |
| Driver OOM | Whole application dies | OOM with collect/Broadcast/plan frames | collect, toPandas, huge broadcast, millions of tasks | Read the stack trace's top frames | Remove the collect; cap broadcast; cut task count | Raise driver memory and maxResultSize |
| Memory overhead exceeded | Executors vanish; no Spark exception | Container killed / OOMKilled / exit 137 | Non-heap: Python workers, native, off-heap | Is this PySpark? how many cores per executor? | Cap pyspark memory; fewer cores; raise overhead + container | Raise executor.memory inside the same container |
| GC overhead | GC Time a large fraction of Task Time | GC overhead limit exceeded | Large live set: caching, churn, fat heaps | Storage tab; is GC growing over the stage? | Unpersist; project before caching; fewer cores | Change the GC algorithm first |
| FetchFailed | Repeated stage attempts; dead executors | FetchFailedException | The block's executor died — cause is elsewhere | Earliest executor death and its own log | Fix that death; shrink oversized blocks | Raise fetch retries and wait |
| Heartbeat timeout | Executor removed mid-stage | no recent heartbeats | Long GC pause, or CPU starvation | That executor's GC ratio; host CPU | Reduce GC pressure; fix oversubscription | Raise spark.network.timeout |
| Executor lost | Executors tab shows removals | ExecutorLostFailure, exit code | Memory kill, preemption, node loss, disk | Exit code; cluster-manager events | Depends on classification — do not skip it | Raise spark.task.maxFailures |
| Stage retry storm | Same stage at attempt 3, 4… | Cascading fetch failures | One death causing map-output loss, repeatedly | Find the FIRST failure; ignore the rest | Fix root death; consider external shuffle service | Raise stage.maxConsecutiveAttempts |
| Output row explosion | Output rows ≫ input rows | Silent | Join fan-out or explode | Duplicate-key test on every dimension | Dedup to the right grain; filter SCD to current | Add DISTINCT and move on — hides a correctness bug |
| Unexpectedly huge scan | Input bytes = table size | Silent | Partition pruning lost | PartitionFilters in the scan node | Bare, correctly-typed partition predicate | Add executors to scan faster |
| Tiny files | Huge task count; tiny per-task input; long pre-stage gap | Slow listing | Upstream writer producing many small files | File count and bytes-per-file for the source | Compact; fix the writer; raise openCostInBytes | Increase maxPartitionBytes only — listing still dominates |
| Millions of tasks | Task count in the millions; driver CPU pinned | Driver GC | Partition sizing or file-count driven explosion | Where does the count come from — files or shuffle? | Coalesce inputs; AQE coalescing; bigger advisory size | Raise driver memory and continue |
| Low CPU, job slow | CPU time ≪ duration; high blocked time | Object-store retries | I/O or network bound | Blocked time; storage request metrics | Better file layout; fewer round trips; more read parallelism | Add executors — more clients on the same throttled path |
| High CPU, job slow | CPU time ≈ duration; uniform tasks; no spill | Quiet | Expensive per-row work | Plan for UDF / regex / JSON nodes | Native expressions; Arrow UDFs; filter earlier | Add memory — it is not a memory problem |
| Cluster underutilised | Active tasks ≪ total slots | Quiet | Not enough partitions, or a driver bottleneck | Task count vs slot count | More partitions; fix driver-side work | Scale up — worsens a driver bottleneck |
| Tasks pending, slots idle | Pending tasks with free slots | Locality waits | Placement/locality, or a resource-profile mismatch | Locality levels; cluster-manager allocation state | Relax locality expectations; check quotas | Request more executors that also cannot be scheduled |
| Broadcast timeout | Job fails during a broadcast exchange | broadcastTimeout exceeded | Build side too large or too slow to collect | Build-side size metric in the SQL tab | Stop broadcasting it; fix statistics | Raise the timeout and broadcast it anyway |
| Broadcast OOM | Driver dies during exchange | OOM with broadcast frames | A hinted or mis-estimated build side | Actual size vs threshold | Remove the hint; refresh statistics; use sort-merge | Raise autoBroadcastJoinThreshold globally |
| Python worker failure | Task failures in Python stages | Traceback, segfault, or nothing | Memory kill, native crash, or a bad record | Presence of a traceback vs container event | Cap Python memory; fix the record; fewer cores | Retry the job and hope |
| collect/toPandas crash | Job dies at an action | maxResultSize or driver OOM | Returning a distributed dataset to one machine | The action in the stack trace | Aggregate first; write to storage; sample | Set maxResultSize=0 |
| Stale statistics | Plan chooses a strategy that contradicts real sizes | Quiet | Statistics missing or outdated | EXPLAIN COST vs actual size metrics | ANALYZE TABLE … COMPUTE STATISTICS | Pin the strategy with a hint and never revisit |
| Slow object-store listing | Minutes before the first task; no active stage | Listing messages | Too many files or partition directories | Object/prefix counts for the path | Compact; reduce partition cardinality; parallel discovery | Increase executor count — the driver is doing the listing |
| Bad node / straggler | Slow tasks concentrated on one executor id | Host-level errors | Degraded hardware or a noisy neighbour | Group slow tasks by executor and host | Enable speculation; drain the host | Salt the join key — the data was never skewed |
| Excessive shuffle | Shuffle bytes ≫ input bytes | Quiet | Plan moving more than necessary | Diff the plan against a known-good run | Broadcast; pre-aggregate; project earlier; compatible layout | Increase network timeouts to survive it |
| Scheduler overhead | Hundreds of tiny jobs; driver busy; executors idle | Quiet | A loop in the driver submitting actions | Job count in the Jobs tab | Express the loop as data; one job | Parallelise the loop with threads on the driver |
| Repeated file reads | Several scan nodes on the same source | Quiet | Subqueries or an un-reused CTE | Count scan nodes; look for ReusedExchange | Conditional aggregation; materialise deliberately | Cache everything |
| Cache pressure | Storage tab shows partial caching; GC up; spill appears | Block eviction messages | Cached data competing with execution | Fraction cached; spill before vs after | Unpersist; project first; pick a disk-inclusive level | Raise storageFraction and starve execution further |
| Job slower after adding cores | More concurrency, new spill, more GC | Container kills in PySpark | Per-task memory fell as cores rose | Heap ÷ cores; Python worker count | Revert cores; raise partitions instead | Add even more cores |
| Long tail, cluster idle | Allocated executors ≫ active during the tail | Quiet | Skew or an unbalanced final stage | Slot utilisation over time | Fix the tail's distribution; lower idle timeout | Nothing — and keep paying for the idle tail |
| Fails only in production | Works on a sample, dies at full scale | Any of the above | A threshold crossed: broadcast size, memory, cardinality | Compare the two plans and the two input volumes | Fix the mechanism the scale exposed | Give production a bigger cluster and forget it |
Twenty production case files.
Composite incidents, in the shape you will actually meet them. Every one follows the same nine beats, because the beats are the method. Numbers are illustrative and internally consistent; the mechanisms are the point.
Stuck at 99% for two hours
- Incident
- Nightly revenue aggregation, normally 35 minutes, still running at 2 h 40 m. SLA breached.
- What is seen
- Jobs tab: one active stage, 4,998 of 5,000 tasks complete. Progress bar has not moved in 90 minutes.
- Wrong first conclusion
- "The cluster is undersized — scale it up and retry." The cluster is 99.96% idle.
- UI evidence
- Stage summary: duration median 31 s, max 2 h 12 m. Shuffle read median 290 MB, max 38 GB. Spill (disk) on the max task: 24 GB. Both slow tasks on different executors — so not a bad host.
- Log evidence
- Nothing. No exception. Skew does not throw; it waits.
- Root cause
- The join key
merchant_idcarries the sentinel-1for unattributed transactions. A billing change three days earlier increased unattributed volume from 4% to 34% of rows. All of them hash to one partition. - Fix
- Unattributed rows cannot match a merchant, so they were being shuffled and then discarded. Filtered before the join, and handled in a separate branch for the LEFT-join reporting path.
WHERE merchant_id IS NOT NULL AND merchant_id <> -1 - Before → after
- Runtime 2 h 40 m → 29 min. Max task 2 h 12 m → 44 s. Max/median shuffle read 131× → 1.4×. Executor-hours 430 → 78.
- Prevention
- Daily key-distribution check on the join key: alert when any single value exceeds 5% of rows. Added to the health scorecard.
Every reducer slow, nothing skewed
- Incident
- A sessionisation job took 84 minutes; it used to take 30, and input has grown 2.5× over six months.
- What is seen
- No stuck tasks. The whole stage is uniformly, patiently slow.
- Wrong first conclusion
- "It scales linearly with data, so this is expected." It did not scale linearly — it scaled worse than linearly, which is the tell.
- UI evidence
- Stage 12: 800 tasks, median shuffle read 7.8 GB, max 8.1 GB (so no skew), spill (disk) 9 TB across the stage. Executors: 16 GB heap, 5 cores → roughly 2 GB unified memory per task.
- Log evidence
- Spilling messages throughout; local volume at 88% utilisation.
- Root cause
spark.sql.shuffle.partitionswas set to 800 two years ago when the data was a quarter of the size. Each partition now vastly exceeds per-task execution memory, so every task spills.- Fix
- Initial partitions raised to 3,200 with a 128 MB advisory size so AQE would not coalesce back. A
REBALANCEhint added before the write to keep output file sizes sane. - Before → after
- Runtime 84 → 39 min. Spill 9 TB → 0. Executor-hours 224 → 104. Output files 800 → 3,180 → 620 after REBALANCE.
- Prevention
- Alert on "any disk spill" for this pipeline. Hard-coded partition counts flagged in review as data-volume-dependent constants.
The driver dies at the last step
- Incident
- A job runs successfully for 50 minutes then dies. The output table is empty. It has failed the same way four nights running.
- What is seen
- All stages green in the History Server. Application state: FAILED.
- Wrong first conclusion
- "The write step is failing — check storage permissions."
- UI evidence
- Final job's last stage completed. No failed tasks anywhere. The failure is after all distributed work — which localises it to the driver immediately.
- Log evidence
- Driver log:
Total size of serialized results of 4,096 tasks (4.1 GiB) is bigger than spark.driver.maxResultSize (1024.0 MiB). Stack showscollectcalled from a validation helper. - Root cause
- A "row count sanity check" written a year earlier as
len(df.collect()). It was harmless when the output was 40,000 rows. The output is now 90 million. - Fix
# before assert len(df.collect()) > 0 # after assert df.limit(1).count() > 0 # or df.isEmpty() where available- Before → after
- Failure at 50 min → success at 48 min. Driver peak heap 7.9 GB → 1.2 GB.
- Prevention
- Lint rule banning
collect()andtoPandas()outside explicitly reviewed code paths.maxResultSizeleft at its default deliberately — it is the guard that caught this.
Containers killed with a half-empty heap
- Incident
- A PySpark feature-engineering job loses executors continuously; it eventually completes in 3× normal time, or fails.
- What is seen
- Executors tab churns: executors appear, die, are replaced.
- Wrong first conclusion
- "Executor OOM — double
spark.executor.memory." This made it fail faster, because the larger heap squeezed the very region that was overflowing. - UI evidence
- No
OutOfMemoryErroranywhere. Peak JVM heap usage around 42% of the configured heap. Executors simply disappear mid-stage. - Log evidence
- Kubernetes:
Reason: OOMKilled, Exit Code: 137. Executor stderr ends mid-line with no exception. - Root cause
- 8 cores per executor → up to 8 concurrent Python worker processes per container, each holding a pandas UDF batch. Python memory lives entirely outside the JVM heap and was pushing the container over its limit.
- Fix
- Cores per executor reduced 8 → 3 (which also tripled per-task JVM memory at no cost), pandas UDF batch size reduced, and
spark.executor.pyspark.memoryset so the budget is explicit rather than implicit. - Before → after
- Executor losses 40+ per run → 0. Runtime 96 → 34 min. Container memory unchanged.
- Prevention
- Executor-loss count added to the scorecard with a zero-tolerance alert. A note in the repo: "for PySpark, cores per executor is a memory setting."
The four-hour fetch-failure storm
- Incident
- A 50-minute job ran for four hours and then failed. Repeatedly.
- What is seen
- Jobs tab: the same stage at attempt 4. Hundreds of
FetchFailedExceptions. - Wrong first conclusion
- "Flaky network — raise
spark.shuffle.io.maxRetriesandspark.stage.maxConsecutiveAttempts." This made failures take longer to arrive. - UI evidence
- Executors tab sorted by removal time: the first death happened 11 minutes in, and everything else follows it. Each stage retry re-executed 40 minutes of upstream map work.
- Log evidence
- The first dead executor's log:
java.io.IOException: No space left on devicewhile writing shuffle output. Later executors: fetch failures pointing at the dead one. - Root cause
- Local scratch volume too small for the shuffle this job produces after a data-volume increase. One executor filled its disk, died, and its map output died with it — forcing re-execution, which produced more shuffle, which filled more disks.
- Fix
- Two changes, sequenced: immediately, larger and faster local volumes to stop the bleeding; then the real fix — a pre-aggregation before the join that cut shuffle write by 68%.
- Before → after
- 4 h and failing → 44 min. Shuffle write 11 TB → 3.5 TB. Stage attempts 4 → 1.
- Prevention
- Alert on local-disk utilisation on executor hosts, and on any stage attempt > 1. Shuffle bytes ÷ input bytes added to the scorecard.
Job slower after we gave it more cores
- Incident
- A change from 4 to 8 cores per executor — same total cores, half the executors — made the job 40% slower.
- What is seen
- Same cluster cost, worse runtime, more GC.
- Wrong first conclusion
- "More concurrency should be faster; something else must have changed." Nothing else changed.
- UI evidence
- Spill (disk) went from 0 to 2.4 TB. GC ratio rose from 4% to 19%. Task durations up across the board, uniformly.
- Log evidence
- Spill messages; occasional heartbeat warnings on the busiest executors.
- Root cause
- Unified memory is per executor, shared by concurrent tasks. Doubling cores halved per-task execution memory, pushing every partition past the spill threshold. Eight tasks also allocated into one heap, doubling GC pressure.
- Fix
- Reverted to 4 cores. Then, separately and with a baseline, raised partition count — which achieved the throughput improvement the core change was trying to buy.
- Before → after
- Runtime 62 → 88 → 41 min after the correct change. Spill 2.4 TB → 0.
- Prevention
- Documented rule: cores per executor may not change without a spill and GC comparison in the PR.
Fifty thousand tiny Parquet files
- Incident
- A downstream report that used to take 4 minutes now takes 25. The source table is the same size.
- What is seen
- A very long pause before any task starts, then thousands of tasks each doing almost nothing.
- Wrong first conclusion
- "The report query needs optimising." The query is fine; the table is not.
- UI evidence
- Scan node: 50,412 files, 151 GB — about 3 MB each. 6 minutes elapse between job submission and the first task. Median task duration 90 ms.
- Log evidence
- Listing messages; object-store request rate spiking during the listing phase.
- Root cause
- The upstream writer switched from hourly batches to a micro-batch that commits every two minutes, multiplying file count by roughly 30 without changing volume.
- Fix
- A compaction maintenance job on the source table, plus a
REBALANCEbefore the upstream write to target sensible file sizes.openCostInBytesraised as an interim measure while the backlog compacted. - Before → after
- Files 50,412 → 296. Pre-stage gap 6 min → 4 s. Runtime 25 → 4 min. Bytes read fell 12% as well, from better encoding in larger row groups.
- Prevention
- File count and average file size per table partition added to the scorecard, with an alert on average file size below a threshold.
The partition filter that stopped filtering
- Incident
- A daily incremental job that reads one day of data started reading four years of it.
- What is seen
- Runtime up 40×. Cost up 40×. Output identical and correct.
- Wrong first conclusion
- "The table has grown." It had — but the job should only ever touch one partition of it.
- UI evidence
- Scan node input bytes equal to the whole table.
PartitionFilters: []— empty.PushedFiltersshows the date predicate being applied after the read. - Log evidence
- Silent. This bug never throws.
- Root cause
- A refactor changed
WHERE ds = '2026-08-26'toWHERE CAST(ds AS DATE) = CURRENT_DATE(). Wrapping the partition column in a cast disabled directory pruning entirely. - Fix
-- compute the constant side, leave the column bare WHERE ds = date_format(current_date(), 'yyyy-MM-dd')- Before → after
- Input 1.4 TB → 36 GB. Runtime 71 → 4 min. Cost per run down 94%.
- Prevention
- A CI check that runs
EXPLAINon each pipeline query and fails if a partitioned source has an emptyPartitionFilters. Input bytes added to the scorecard with a ratio alert.
Forty-times output from a dimension nobody touched
- Incident
- Overnight, output rows went from 210 million to 8.4 billion. Runtime tripled. Finance noticed before engineering did.
- What is seen
- Massive shuffle, spill everywhere, downstream dashboards showing implausible revenue.
- Wrong first conclusion
- "Traffic spiked — we need a bigger cluster for the new volume."
- UI evidence
- Input rows flat at 210 M. Output rows 8.4 B. Output ÷ input = 40.0 — a suspiciously round number, and round numbers mean multiplication, not growth.
- Log evidence
- Silent — a fan-out is a correct execution of an incorrect query.
- Root cause
- A dimension load ran 40 times during a retry loop in an upstream pipeline, appending rather than overwriting. Every fact row now matched 40 dimension rows.
- Fix
- Upstream dedup and a re-run. In the consuming pipeline, a uniqueness assertion on the dimension key that fails the job rather than producing 40× output.
- Before → after
- Output 8.4 B → 210 M. Runtime 132 → 44 min. Two days of downstream reporting reissued.
- Prevention
- Uniqueness test on every dimension key in CI and at runtime. Output-÷-input-rows alert on the scorecard — this ratio catches the entire class.
The broadcast table that grew up
- Incident
- A stable pipeline started failing intermittently, then permanently, with the driver dying during execution.
- What is seen
- Failures at inconsistent points; a driver OOM after several minutes of apparently normal execution.
- Wrong first conclusion
- "Transient infrastructure issue — add a retry." It failed on every retry.
- UI evidence
- SQL tab: the
BroadcastExchangenode reports a build side of 3.8 GB. Driver memory: 4 GB. - Log evidence
- Driver:
OutOfMemoryError: Java heap spacewith broadcast frames in the stack. Earlier runs showbroadcastTimeoutwarnings — the early symptom nobody investigated. - Root cause
- An explicit
/*+ BROADCAST(dim_product) */hint added 18 months ago when the table was 6 MB. It now has 40 million rows. The hint overrides the size threshold, so Spark obeyed instructions it should have refused. - Fix
- Hint removed; statistics refreshed so the optimizer chooses correctly on its own. The join now uses sort-merge and completes comfortably.
- Before → after
- Failing → 51 min. Driver peak heap 3.9 GB → 0.8 GB.
- Prevention
- Every broadcast hint in the repo paired with a size assertion. Broadcast timeout warnings promoted from ignorable to alertable — they are the early warning for exactly this.
A Python UDF eats the cluster
- Incident
- A PR described as "extract the country code more readably" tripled the pipeline's runtime and cost.
- What is seen
- Uniformly slow tasks. No spill, no skew, no failures. Nothing looks broken.
- Wrong first conclusion
- "Data volume must have grown." Input bytes were identical to the previous run.
- UI evidence
- Plan now contains a
BatchEvalPythonnode between two previously-fused regions. CPU time ÷ task duration is near 1.0 — pure compute. And critically:PushedFilterson the source scan is now empty where it previously carried a predicate. - Log evidence
- Quiet. Slow is not an error.
- Root cause
- Two costs, not one. Row-at-a-time Python serialization for 4.2 billion rows, and the UDF acting as an optimizer barrier that prevented a filter from being pushed into the scan — so the job also read 6× more data than before.
- Fix
- Reverted to native expressions (
substring+upper+ aCASE). Restored both the fusion and the pushdown. - Before → after
- Runtime 148 → 46 min. Input bytes 3.1 TB → 510 GB. Executor-hours 390 → 118.
- Prevention
- Review guidance: any new UDF requires a note on why a native expression is insufficient, plus a before/after on input bytes.
One session, four hundred thousand page views
- Incident
- A clickstream flattening job intermittently ran 5× long. On some days it was fine.
- What is seen
- A stage where a handful of tasks take forever, but only on some days.
- Wrong first conclusion
- "Skew on
user_id— let's salt the join." There was no join in that stage. - UI evidence
- The
Generatenode's output rows are 340× its input rows. The stage after the explode has max task duration 90× the median, while the stage before it is perfectly balanced. - Log evidence
- Occasional executor OOM on the slow tasks.
- Root cause
- A crawler and a handful of automated sessions produced arrays with hundreds of thousands of elements. One input row became one enormous output partition. AQE cannot help here — the skew is created downstream of the shuffle boundary that AQE reasons about.
- Fix
- Bounded the array before exploding, filtered known bot sessions, and added an explicit repartition after the explode so downstream stages see a balanced distribution.
- Before → after
- Worst-day runtime 3 h 10 m → 38 min. Post-explode max/median 90× → 2.1×. Executor OOMs eliminated.
- Prevention
- Array-length percentiles (p50/p95/p99/max) tracked daily for every exploded column.
Forty-five percent of the cluster collecting garbage
- Incident
- A job got progressively slower each week without any data growth to explain it. Executors occasionally disappeared.
- What is seen
- Tasks get slower as the stage progresses. Occasional heartbeat-timeout executor losses.
- Wrong first conclusion
- "Network instability — raise the heartbeat timeout." That hid the symptom for two weeks.
- UI evidence
- Executors tab: Task Time 4.2 h, GC Time 1.9 h → 45%. Storage tab: three cached DataFrames totalling 210 GB, one showing 62% cached — the rest evicted and being recomputed.
- Log evidence
- Long GC pauses; block-eviction messages; heartbeat warnings immediately after the longest pauses.
- Root cause
- Caches added incrementally over months, none unpersisted, most read only once. They filled the old generation with long-lived objects, starved execution memory, and caused both the GC pressure and the eviction-recompute cost.
- Fix
- Two of the three caches deleted (each dataset was read once). The third narrowed by projecting six columns instead of sixty, and explicitly unpersisted after its last use.
- Before → after
- GC ratio 45% → 4%. Runtime 97 → 36 min. Executor losses per run 6 → 0. Heartbeat timeout restored to its default.
- Prevention
- GC ratio on the scorecard. Review rule: every
cache()must state where the second read happens, and every cache needs a matchingunpersist().
Five hundred executors, thirty doing work
- Incident
- A backfill provisioned with 500 executors ran for 9 hours at roughly 6% cluster CPU.
- What is seen
- An enormous, expensive, idle cluster and a job that will not finish.
- Wrong first conclusion
- "Not enough parallelism — request 1,000 executors." This made it slower.
- UI evidence
- Jobs tab shows 4,380 separate jobs, each with two or three tiny stages. Active tasks rarely exceed 30. Driver CPU pinned at 100% throughout.
- Log evidence
- Continuous plan-compilation and job-submission activity on the driver; driver GC elevated.
- Root cause
- The backfill was a Python
forloop over 4,380 dates, calling an action per iteration. Each iteration is a full job with plan compilation, scheduling and teardown. The executors were never the constraint; the driver was. - Fix
- Rewrote the loop as data: read the whole date range in one job, with the date as a partition column, and write partitioned output in a single action.
- Before → after
- Runtime 9 h → 26 min. Jobs 4,380 → 1. Executors reduced 500 → 120, and it was still faster. Cost down roughly 95%.
- Prevention
- Job-count-per-run added to the scorecard; anything above a small threshold is treated as a design smell.
Listing takes longer than computing
- Incident
- A query over a partitioned table spends 14 minutes before its first task and 90 seconds computing.
- What is seen
- A long, silent period where the application is running and nothing is happening.
- Wrong first conclusion
- "The cluster is slow to start." Cluster start was 45 seconds, visible in the orchestrator.
- UI evidence
- Application start to first task: 14 min 20 s. No stage active during it. Scan node eventually reports 2.1 million partition directories discovered.
- Log evidence
- Partition-discovery messages; object-store request metrics showing sustained LIST volume.
- Root cause
- The table was partitioned by
(ds, customer_id). With 4 million customers this creates an unbounded number of directories, most containing a single tiny file. Listing dominates every query regardless of how selective the filter is. - Fix
- Repartitioned the table by
dsonly, with clustering/sorting oncustomer_idinside each day so selective reads still skip data. Parallel partition discovery tuned as an interim mitigation. - Before → after
- Pre-stage gap 14 min → 6 s. Directories 2.1 M → 1,460. Total runtime 16 min → 2 min.
- Prevention
- Design rule: partition columns must have bounded, low cardinality. High-cardinality access patterns are served by clustering and statistics, never by directories.
Stale statistics choose the wrong join
- Incident
- A query that ran in 6 minutes for months suddenly takes 71. No deploy, no data growth.
- What is seen
- Enormous shuffle where there used to be almost none.
- Wrong first conclusion
- "Cluster contention." It reproduced on an idle cluster.
- UI evidence
- Plan diff against the last good run:
BroadcastHashJoinbecameSortMergeJoin. Shuffle write 40 GB → 2.1 TB. The build side's actual size in the SQL tab is 74 MB — comfortably broadcastable. - Log evidence
- Silent.
- Root cause
- A table maintenance operation reset the table's statistics. With no statistics, the optimizer fell back to a size estimate derived from a much larger raw footprint, crossed the broadcast threshold, and chose sort-merge.
- Fix
ANALYZE TABLE … COMPUTE STATISTICS FOR COLUMNS …, then verified the plan reverted to a broadcast join on its own — no hint added.- Before → after
- Runtime 71 → 6 min. Shuffle 2.1 TB → 40 GB.
- Prevention
- Statistics refresh added to the table's maintenance schedule. SQL plan fingerprint added to the scorecard so a plan flip alerts before the runtime does.
The caching "optimization" that cost 30%
- Incident
- A PR titled "cache the intermediate for speed" made the job 30% slower. It was merged because nobody measured.
- What is seen
- Slower everywhere, no obvious culprit stage.
- Wrong first conclusion
- "Unrelated infrastructure change." It reproduced exactly on revert.
- UI evidence
- Storage tab: the cached DataFrame is 71% cached — 29% evicted and recomputed on access. Spill appeared in two downstream stages that previously had none. GC ratio 6% → 14%.
- Log evidence
- Block eviction messages; spill messages.
- Root cause
- The dataset was 480 GB against far less available storage memory. Caching took memory from execution (causing the new spill), added long-lived objects (causing the GC rise), and still did not avoid recomputation for the evicted third.
- Fix
- Removed the cache. The dataset was read twice, but the second read was a cheap pruned scan — far cheaper than the memory contention the cache created.
- Before → after
- Runtime 78 → 54 min (better than the 60 min pre-PR baseline, because the investigation also found a projection to add). Spill back to 0.
- Prevention
- Caching changes require a before/after on runtime, spill and GC in the PR description.
A raised timeout hides an unhealthy host
- Incident
- Intermittent executor losses were "fixed" three months ago by raising
spark.network.timeoutto 600s. Runtime has been creeping up ever since. - What is seen
- No failures any more — just a job that is 40% slower than it was.
- Wrong first conclusion
- "Gradual data growth." Input bytes were flat.
- UI evidence
- Slow tasks sorted by duration: 8 of the top 10 on the same executor, across multiple stages, over multiple days. That executor's input sizes are ordinary; only its durations are not.
- Log evidence
- Host metrics for that node: disk I/O wait an order of magnitude above its peers. A degraded local volume.
- Root cause
- One host had a failing disk. Raising the timeout stopped Spark from evicting it, so instead of losing the executor quickly the job kept scheduling work onto a machine that could not do it.
- Fix
- Host drained and replaced. Timeout restored to its default. Speculative execution enabled for this workload after confirming its writes are idempotent.
- Before → after
- Runtime 84 → 58 min. Straggler tasks eliminated. Executor losses remained at zero — because the cause was gone, not masked.
- Prevention
- Per-executor slow-task attribution added to the post-run report. Rule: raising a timeout requires a written justification and an expiry date.
The source doubled and nobody said anything
- Incident
- Runtime doubled overnight. Three engineers spent a day tuning Spark.
- What is seen
- Everything proportionally slower. No anomalies, no failures, no skew.
- Wrong first conclusion
- "Something regressed in our job." Nothing had.
- UI evidence
- Input bytes 2.0 TB → 4.1 TB. Input rows 3.9 B → 7.8 B. Every other ratio — shuffle ÷ input, output ÷ input, bytes ÷ row — unchanged. The job scaled exactly as it should.
- Log evidence
- Silent, correctly.
- Root cause
- An upstream team enabled a backfill that re-emitted 18 months of history into the same landing zone, alongside the current day. Spark was not slow; it was processing twice the data, faithfully.
- Fix
- Nothing in Spark. Upstream corrected the backfill target. The consuming job's date filter was tightened so a repeat cannot silently double its input.
- Before → after
- Runtime back to baseline with zero Spark changes. One day of three engineers' time spent to learn that input bytes should have been the first thing checked.
- Prevention
- Input bytes and rows on the scorecard, with a same-weekday ratio alert. This case is the reason that alert exists.
40% faster, 3× the bill
- Incident
- A celebrated optimization cut runtime from 90 to 54 minutes. The monthly platform bill rose 31%, and the increase was traced to this pipeline.
- What is seen
- A dashboard showing a runtime improvement. No cost dashboard existed.
- Wrong first conclusion
- "Cost rose because data grew." Data grew 4% that month.
- UI evidence
- Executor-hours 150 → 438. Slot utilisation during the final 20 minutes: 7% — the cluster was sized for the widest stage and idled through a long skewed tail.
- Log evidence
- Silent.
- Root cause
- The "optimization" was raising
maxExecutorsfrom 100 to 500. It bought parallelism for the wide middle stage and paid for 500 executors through a tail that only two tasks were using. Nothing about the job's work changed. - Fix
- Reverted to 180 executors and fixed the tail's skew instead. Runtime 58 minutes — two minutes short of the 500-executor version — at 174 executor-hours.
- Before → after
- Runtime 90 → 54 → 58 min. Executor-hours 150 → 438 → 174. Cost per TB down 9% against the original baseline, versus up 180% for the "faster" version.
- Prevention
- Executor-hours and cost per TB added next to runtime on every pipeline dashboard. Performance PRs must report both. A change that improves one and degrades the other is a trade-off to be argued, not a win to be announced.
Eighteen interview scenarios.
▶ Take the Spark Skill Check — 159 auto-graded questions drawn from this handbook, 20 per attempt, with an explanation on every answer. The drills below are the spoken version; the Skill Check is the graded one.
Twenty-one more are embedded through the parts above, at the end of the section each one belongs to — thirty-nine drills in total. These eighteen are fresh and stand alone. The pattern is deliberate: the weak answer reaches for a resource, the good answer reaches for a cause, and the senior answer reaches for evidence that discriminates between causes — and then says what it would cost.
"How do you approach a Spark job you've never seen that is failing?"
- Weak
- "Read the error and fix it."
- Good
- "Find the exception, work out whether it's driver or executor, then dig into the relevant tab."
- Senior
- "Three questions before any code. Did the application start at all — if there's no application ID, it's orchestration, provisioning or packaging and Spark is irrelevant. If it started, is the failure driver-side or executor-side, because those share almost no root causes. Then I read the exception chain from the bottom up and keep descending past Spark internals until something names a resource or a value — heap, container limit, disk, a file, a column. If the chain bottoms out in a fetch failure or an executor loss, the real cause is on another host and I go read that executor's log. I also capture the application ID, the input volume and the historical runtime at the start, because I'll need them to prove whatever I do next actually helped."
"What is the difference between repartition and coalesce, and when has that difference hurt you?"
- Weak
- "
repartitionincreases partitions,coalescedecreases them." - Good
- "
repartitiondoes a full shuffle and gives even partitions;coalesceavoids the shuffle by merging, so it's cheaper but uneven." - Senior
- "The mechanical difference is the shuffle, but the operational difference is that
coalescecan't create a stage boundary — precisely because it doesn't shuffle. So reduced parallelism propagates backwards through every narrow dependency in that stage. The classic injury is.coalesce(10)added before a write to reduce file count: the write does produce 10 files, and the entire upstream computation now runs with 10 tasks. I've seen that turn eight minutes into ninety. You spot it in the UI as a final stage with exactly N very long tasks. If I want fewer output files and I'm willing to pay for the boundary,repartitionworks; if I want even sizes without picking a number, aREBALANCEhint lets AQE target a size instead."
"AQE is on by default. What does that actually change about how you tune?"
- Weak
- "It optimises things automatically so you don't have to tune."
- Good
- "It coalesces shuffle partitions, can convert joins to broadcast at runtime, and can split skewed partitions — so hard-coded partition counts matter less."
- Senior
- "It moves the unit of tuning from a count to a size, and it moves the plan I should be reading from the static
EXPLAINto the SQL tab. Practically: I set the initial shuffle partition count generously, because AQE can merge partitions but cannot split ones that were never created, and then I tune the advisory partition size, which is the actual dial. I stop trustingEXPLAINoutput as a description of what ran, because AQE re-plans stage by stage using measured sizes. And I learn what AQE will not do: its skew handling targets skewed shuffle partitions in joins, so it won't help a skewed aggregation group, won't help skew created by anexplodedownstream, and won't help when one input file is enormous before any shuffle exists. Those are still mine to fix."
"Your job spills 4 TB to disk. Walk me from that number to a fix."
- Weak
- "Increase executor memory until it stops spilling."
- Good
- "Spill means partitions don't fit in execution memory — I'd increase the partition count so each one is smaller."
- Senior
- "I'd first establish whether it's uniform or concentrated, because that changes everything. If max task input is close to median, every partition is too big and the fix is arithmetic: per-task execution memory is roughly heap × memory.fraction ÷ cores, so I compute that, compare it to median partition size, and raise the partition count until the ratio is comfortable — or halve the cores per executor, which doubles per-task memory for free. If spill is concentrated in a few tasks, it's skew and partition count won't help; I go find the hot key. Either way the confirming metric is spill going to zero, not runtime falling — runtime can improve for unrelated reasons and I'd rather know I fixed the mechanism. I'd also check local disk headroom, because sustained spill is what fills scratch volumes and turns a memory problem into an executor-loss cascade."
"How do you find out whether a join is broadcasting, and what would you do if you wanted it to and it isn't?"
- Weak
- "Add a broadcast hint."
- Good
- "Check the plan for
BroadcastHashJoin. If it's not there, either the table is over the threshold or statistics are stale — I'd runANALYZE TABLE." - Senior
- "I check the SQL tab of the finished run rather than a static
EXPLAIN, because AQE can promote a join to broadcast after measuring the real shuffle output — so the final plan is the only authority. If it's a sort-merge join and I think it shouldn't be, I look at the build side's actual size metric versus the threshold. Two common gaps: statistics are missing so the estimate came from a raw footprint much larger than reality, or the estimate is of the in-memory representation, which for compressed columnar data is substantially bigger than the file size people quote. Fixing statistics is the right first move because it lets the optimizer decide correctly everywhere, not just here. I'd reach for a hint only as a last resort and I'd pair it with a size assertion, because a hint overrides the safety check — that's how tables that quietly grow end up taking down the driver."
"When is spark.sql.autoBroadcastJoinThreshold = -1 the right setting?"
- Weak
- "Never — broadcasting is always faster."
- Good
- "When broadcasts are causing driver memory problems, disabling them can stabilise the job."
- Senior
- "It's a blunt instrument and I'd treat it as a diagnostic before treating it as a fix. Setting it to −1 disables auto-broadcast entirely, which is genuinely useful in two situations: as an experiment, to confirm that broadcasts are the cause of driver instability; and in an environment where estimates are systematically unreliable — no statistics, heavily filtered build sides — so the optimizer keeps choosing broadcast for things that aren't small. The cost is that every join in the application now shuffles, including the ones where broadcast was correct and valuable, so it usually trades one incident for a permanent tax. The targeted alternatives are fixing statistics, lowering the threshold rather than disabling it, or using a
MERGE/SHUFFLE_HASHhint on the specific offending join."
"A stage has 200 tasks and the cluster has 800 slots. What is wrong and what isn't?"
- Weak
- "Nothing — 200 tasks is the default."
- Good
- "We're using a quarter of the cluster. The shuffle partition count is probably at the default of 200 and should be higher."
- Senior
- "Two things are true. Structurally, 600 slots are idle for the duration of that stage, so we're paying for four times the capacity we're using — that's the cost finding. But whether it's a performance problem depends on the partition size: if each of those 200 partitions is small and the stage finishes in twenty seconds, raising the count buys nothing and adds scheduling overhead. What tells me it's a real problem is spill, or task durations long enough that the idle capacity represents lost wall clock. I'd also check whether the 200 is the default shuffle partition count or the result of AQE coalescing — those look identical in the task table and mean opposite things. If AQE coalesced down to 200 because that hits the advisory size, the system is working correctly and the right lever is the advisory size, not the count."
"Explain what happens physically when Spark shuffles."
- Weak
- "Data moves between executors over the network."
- Good
- "Map tasks partition their output by key and write it to local disk; reduce tasks fetch the blocks for their partition from every map task and combine them."
- Senior
- "Map side: each task partitions its rows by the target partition id, sorts within partitions, serializes and compresses, and writes a data file plus an index file to local disk. Reduce side: each reduce task fetches its partition's block from every map executor, buffering in execution memory and spilling if it doesn't fit. So one shuffle costs CPU for serialization and sorting, memory for buffers on both sides, local disk for the whole volume written and read, and network for every byte crossing once. The consequence people underrate is durability: shuffle output isn't replicated, it lives on the producing executor's disk, so losing that executor means re-running the map stage that produced it. That's why a single memory-killed executor can cascade into hours of re-execution, and why an external shuffle service changes the economics — it decouples shuffle data from executor lifetime."
"Your job reads a 500 GB table but only needs one day of it. How do you confirm pruning is working?"
- Weak
- "The
WHEREclause handles it." - Good
- "Check the input bytes in the Spark UI — they should be far less than 500 GB."
- Senior
- "Input bytes is the outcome; I want the mechanism too, because there are three different things that could be doing the reduction and they fail independently. In
EXPLAIN FORMATTEDor the SQL tab I look at the scan node for three fields:PartitionFiltersshould carry the date predicate — that's directory-level pruning and it's the big one;PushedFiltersshould carry any other predicates that can be evaluated against row-group statistics; andReadSchemashould list only the columns I actually use. Then I check the metrics: number of partitions read, number of files read, and bytes read. IfPartitionFiltersis empty, something wrapped the partition column in a function or compared it to the wrong type, and I'd fix that before anything else, because it's usually a one-character bug with a very large price."
"When would you deliberately increase the number of shuffle partitions even though it creates more tasks?"
- Weak
- "When the job is slow."
- Good
- "When partitions are too big and spilling — smaller partitions fit in memory."
- Senior
- "Three situations. One: spill — each partition exceeds per-task execution memory, and more partitions is the direct fix. Two: idle capacity — fewer tasks than slots means some of the cluster does nothing regardless of how fast each task is. Three, and this is the AQE-era reason: I set the initial count high deliberately. AQE coalescing merges small partitions, and its skew handling can split a partition that qualifies as skewed, but neither helps when every partition is uniformly oversized — so a generous starting count is cheap insurance and a stingy one is a ceiling. The cost I'm accepting is scheduling overhead per task, more shuffle blocks — M×R grows with R — and potentially more output files. So I'd pair it with a
REBALANCEbefore the write if output file count matters, and I'd watch median task duration: once it's in the tens of milliseconds, I've gone too far."
"Why might a job fail in production but succeed on a 1% sample in development?"
- Weak
- "Production has more data, so it needs more memory."
- Good
- "Scale exposes problems the sample doesn't have — skew, memory limits, broadcast thresholds."
- Senior
- "The interesting answer is that it's usually a threshold being crossed, not a gradual scaling, so the failure is discontinuous. Candidates I'd check in order: the build side of a join crossed the broadcast threshold, so the plan is now a completely different physical plan — a 1% sample and full data can produce different join strategies, which means dev never tested the code path that fails. Skew is invisible at 1%: a key that's 34% of production may be absent or tiny in a sample. Memory is per-partition, so a sample's partitions fit and production's don't. Cardinality effects like fan-out multiply, so a 40× fan-out on 1% is still small. And in PySpark, worker memory scales with concurrency and batch size, not with total data, so a sample may never hit the container limit. The practical response is to compare the two plans, not just the two volumes — a plan difference explains most of these instantly."
"What's the difference between spark.default.parallelism and spark.sql.shuffle.partitions?"
- Weak
- "They're basically the same thing."
- Good
- "
default.parallelismapplies to RDD operations;sql.shuffle.partitionsapplies to DataFrame and SQL shuffles." - Senior
- "They govern different code paths and that trips people up in a specific way: someone sets
spark.default.parallelism, sees no change in a DataFrame job, and concludes the setting 'doesn't work'. In the SQL/DataFrame world the shuffle partition count comes fromspark.sql.shuffle.partitions, defaulting to 200 in Apache Spark, and with AQE on that's the initial count before coalescing.spark.default.parallelismis the RDD-level default and in cluster mode defaults to the total number of cores. Almost all modern pipeline code is DataFrame or SQL, so the SQL setting is the one that matters — but if you have any RDD operations mixed in, or a library that drops to RDDs, both are live at once, which is worth knowing before you spend an hour confused."
"How would you reduce a pipeline's cost by 40% without breaking its SLA?"
- Weak
- "Use smaller instances."
- Good
- "Find the headroom between finish time and SLA, then shrink the cluster and remove wasted work like re-reads and unnecessary shuffles."
- Senior
- "I'd separate cost into work I can delete and capacity I can stop buying, and attack the first one first because it improves both axes. Work I can delete: bytes scanned — restore pruning, project columns, stop re-reading the same source in three subqueries; bytes shuffled — broadcast what should broadcast, pre-aggregate before joins, remove defensive repartitions; bytes written — make it incremental instead of a full recompute; and re-executed work — any stage attempt above one is paid for twice. Capacity I can stop buying: look at slot utilisation over time, because most clusters are sized for their widest stage and then idle through a long tail, so fixing the tail's skew often lets me cut the cluster substantially with almost no runtime change. Then I'd check the SLA is real — a job finishing at 03:00 against an 06:00 deadline has three hours of headroom that's currently being spent on nothing. I'd report it as executor-hours and cost per TB, not runtime, because those are the numbers that stay honest."
"An executor keeps dying. Give me your classification procedure."
- Weak
- "Increase memory — it's probably OOM."
- Good
- "Check whether there's an OOM in the executor log, and check the exit code to see if the container was killed."
- Senior
- "I want to end up in exactly one of four buckets and each has a different fix. First: does the executor's own log end with a stack trace or mid-sentence? A stack trace means the JVM threw — if it's a heap OOM I go to the skew-versus-uniform question. Ending mid-sentence means it was killed from outside, and then the exit code discriminates: 137 is a memory kill by the container runtime, 143 is a SIGTERM which usually means decommissioning or preemption. Second: is it always the same host? Then it's hardware or a noisy neighbour, and the fix is draining the node, not tuning Spark. Third: did several executors die simultaneously? That's an infrastructure event — spot reclamation, a node group replacement — and the response is architectural, like shuffle tracking or an external shuffle service so we don't lose map output. Fourth: is it PySpark with high cores per executor? Then Python worker memory is the leading hypothesis regardless of what the heap looks like. I'd get to the right bucket in a couple of minutes and I'd resist doing anything before I have."
"What does spark.memory.fraction do and when have you changed it?"
- Weak
- "It controls how much memory Spark uses. I'd increase it to give Spark more."
- Good
- "It's the share of usable heap given to unified execution-plus-storage memory, defaulting to 0.6 in Apache Spark. The rest is user memory for your own objects and Spark internals."
- Senior
- "It splits the usable heap between the unified execution-and-storage pool and everything else — your objects, closures, Spark's own structures. Default 0.6 in Apache Spark, and I've almost never changed it, which I think is the correct answer. Raising it gives joins and aggregations more room, but it takes that room from user memory, and if you're running code that materialises objects — UDFs, typed lambdas, big lookup structures — you can turn a spill into an OOM, which is a strictly worse failure. The reason it's rarely the right knob is that the things it competes with are almost always fixable more directly: smaller partitions, fewer cores per executor, less caching, fewer materialised objects. If I did change it I'd want a specific hypothesis — 'this stage is execution-memory-bound and user memory is provably underused' — and the confirming metric would be spill falling with no new OOM, measured over several runs."
"How do you detect skew without opening the Spark UI?"
- Weak
- "You can't — you need the UI."
- Good
- "Profile the key distribution with a
GROUP BY … ORDER BY COUNT(*) DESCand look for dominant values." - Senior
- "Two approaches and I'd want both. From the data: profile the join or group key with counts and percentage-of-total, and also with bytes per key rather than just rows, because a key with fewer but much wider records can be the expensive one. I specifically look for sentinels — 0, −1, empty string, 'UNKNOWN' — and for NULL, since NULLs all hash together and in an inner join are shuffled and then discarded, which is pure waste. From the run: pull per-stage task quantiles from the Spark REST API rather than the UI, compare shuffle-read bytes at p50 against the max, and store that ratio with every run. That turns skew detection into a monitored metric instead of a manual investigation, which is the difference between finding it during an incident and finding it in a dashboard the morning it starts."
"What would you put in a Spark pipeline's runbook?"
- Weak
- "How to restart it."
- Good
- "Restart procedure, the SLA, who owns the upstream data, and the known failure modes with their fixes."
- Senior
- "The runbook's job is to make an incident boring for someone who has never seen this pipeline. So: what it does and who consumes it; the SLA and what happens if it's missed; the baseline numbers — median runtime, input bytes, shuffle bytes, executor-hours — because triage is comparison and without a baseline there's nothing to compare to; the collection checklist, meaning the exact list of things to capture before changing anything, with the REST API calls to get them; known failure modes with their evidence signatures and fixes, written as 'if you see X in the stage metrics, it's Y'; every non-default configuration with the reason it exists and an expiry date; the upstream dependencies and their owners; and explicitly, what not to do — the knee-jerk fixes that have hurt this pipeline before. That last section is the most valuable and the one nobody writes."
"What's the most important thing you've learned about tuning Spark?"
- Weak
- "Always enable AQE and use broadcast joins where possible."
- Good
- "Find the bottleneck before changing anything, and change one thing at a time."
- Senior
- "That most Spark problems are not Spark problems. The genuinely expensive incidents I've worked came from data — a sentinel key that grew, a dimension that duplicated, an upstream writer that changed file sizes, a source that reprocessed history — and every one of them presented as 'Spark is slow'. So the habit that pays is capturing volume and distribution metrics per run, because they turn a day of Spark UI archaeology into a one-query answer. Second to that: optimise the query and the data layout before you scale resources, because layout fixes compound and capacity fixes just recur at a higher price. And third, tune by evidence, not folklore — every config I keep has a sentence next to it saying which measurement justified it, and the ones that can't produce that sentence get removed."
Configuration reference, with context.
Every default below is the Apache Spark default. Databricks, EMR, Glue, Dataproc, Synapse/Fabric and internal platforms all ship different values, and some ship different implementations — different shuffle managers, different commit protocols, occasionally a modified optimizer. A "Spark default" quoted from a vendor's documentation is a vendor default.
The only authoritative source for your job is the Environment tab of your own run, or /api/v1/applications/<id>/environment. Check there before arguing about a default, and check there again after someone tells you they "didn't change anything."
Defaults also change between versions. Where a value below has moved historically, it is marked Version-dependent — verify against your release's SQL configuration and application property documentation rather than trusting any handbook, including this one.
Parallelism
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.sql.shuffle.partitions | Partitions produced by a SQL/DataFrame shuffle | 200 | Inspect whenever there is spill or idle capacity. Helps when partitions exceed per-task execution memory, or when task count is below slot count. Hides nothing — but setting it low to "reduce overhead" caps AQE: coalescing only merges, and skew splitting applies only to partitions that qualify as skewed. |
spark.default.parallelism | Default partitions for RDD operations | total cores (cluster mode) | Inspect only if RDD code is involved. Helps for RDD-based pipelines. Hides confusion: it does not affect DataFrame/SQL shuffles at all. |
spark.sql.files.maxPartitionBytes | Target bytes per read partition | 128 MB | Inspect when read-stage tasks are too big or too many. Helps when per-row work is expensive and you want more read parallelism. Hides a small-file problem — listing cost is unaffected by it. |
Adaptive Query Execution
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.sql.adaptive.enabled | Master switch for runtime re-planning | true (3.2.0+) false in 3.0/3.1 | Inspect first, every time — legacy job configs frequently disable it. Helps almost always. Hides nothing; disabling it to "make plans predictable" gives up coalescing, skew splitting and runtime join conversion. |
spark.sql.adaptive.coalescePartitions.enabled | Merge small post-shuffle partitions | true | Inspect when task counts are enormous. Helps by making a generous initial partition count safe. Hides nothing. |
spark.sql.adaptive.advisoryPartitionSizeInBytes | Target partition size for coalescing and skew splitting | 64 MB | Inspect whenever tuning partition sizing — this is the real dial. Helps: raise for wide cheap rows, lower for expensive per-row work. Hides nothing, but a large value can silently undo an increase in the initial partition count. |
spark.sql.adaptive.skewJoin.enabled | Split skewed shuffle partitions in joins | true | Inspect when you have visible skew and AQE "did nothing". Helps for join skew. Hides nothing — but it cannot address aggregation skew, explode-generated skew, or skew in the input files. |
spark.sql.adaptive.skewJoin.skewedPartitionFactor / …ThresholdInBytes | Relative and absolute conditions for "skewed" values have changed across 3.x | read from your Environment tab | Inspect when the skew is real but untreated. Helps when a partition exceeds one condition but not the other — both must be exceeded. Hides the underlying key distribution, which is still worth fixing. |
spark.sql.adaptive.localShuffleReader.enabled | Read shuffle output locally after a runtime broadcast conversion | true | Inspect rarely. Helps automatically. Hides nothing. |
spark.sql.adaptive.autoBroadcastJoinThreshold | Runtime broadcast threshold used by AQE | follows spark.sql.autoBroadcastJoinThreshold | Inspect when a join you expected AQE to convert stayed as sort-merge. Helps when the runtime size is known to be safe. Hides driver pressure — the build side still passes through the driver. |
Broadcast
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.sql.autoBroadcastJoinThreshold | Estimated build-side size below which broadcast is chosen; -1 disables | 10 MB | Inspect on any join-strategy surprise. Helps raised modestly when you know the small side and the driver's capacity. Hides stale statistics — if the estimate is wrong, raising the threshold amplifies the error rather than correcting it. |
spark.sql.broadcastTimeout | Seconds to wait for a broadcast to be built | 300 | Inspect when timeouts appear — they are an early warning that the build side is growing. Helps rarely. Hides a table that has outgrown broadcasting; the next symptom is a driver OOM. |
Memory
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.executor.memory | JVM heap per executor | 1g | Inspect on any heap OOM. Helps when the per-task working set is genuinely irreducible. Hides skew, under-partitioning and over-caching — and buys longer GC pauses. |
spark.executor.cores | Concurrent tasks per executor | 1 (varies by cluster manager) | Inspect on any memory or GC problem. Helps lowered — it raises per-task memory at zero cost and cuts Python worker count. Hides nothing; this is one of the most honest knobs available. |
spark.memory.fraction | Share of usable heap for execution + storage | 0.6 | Inspect late, not early. Helps when execution is provably starved and user memory is idle. Hides object-heavy user code — raising it can convert a spill into an OOM. |
spark.memory.storageFraction | Floor of unified memory guaranteed to cached blocks | 0.5 | Inspect when caching is deliberate and eviction is measurable. Helps in cache-heavy workloads. Hides the fact that you are probably caching something you should not. |
spark.driver.memory | Driver JVM heap | 1g | Inspect on any driver OOM, huge task counts, or big broadcasts. Helps when the driver's job is legitimately large. Hides a collect(). |
spark.driver.maxResultSize | Cap on serialized results returned to the driver; 0 = unlimited | 1g | Inspect on result-size failures. Helps when you deliberately need a large result and have sized the driver for its deserialized form. Hides — this is the airbag; setting it to 0 removes your only protection against a driver kill. |
Memory overhead and non-JVM memory
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.executor.memoryOverhead | Container memory outside the heap | computed from executor memory with a floor — verify the factor and floor for your version | Inspect on every container kill. Helps when non-heap use is genuinely large. Hides unbounded Python or native memory growth — raise it and it will overflow again later. |
spark.executor.pyspark.memory | Explicit budget for Python worker memory | not set | Inspect on any PySpark container kill. Helps by making an implicit budget explicit and enforceable. Hides nothing — but it does not shrink what the workers actually need. |
spark.memory.offHeap.enabled / spark.memory.offHeap.size | Off-heap execution memory | false / 0 | Inspect when GC pauses are the constraint. Helps for large execution memory without heap-scan cost. Hides — enabling it without enlarging the container simply reduces usable memory and causes container kills. |
spark.driver.memoryOverhead | Same, for the driver | computed as above | Inspect when the driver is killed rather than throwing. Helps on Python-heavy driver work. Hides a toPandas(). |
Dynamic allocation
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.dynamicAllocation.enabled | Scale executors with demand | false often true on managed platforms | Inspect when utilisation is uneven across stages. Helps for variable-width jobs. Hides nothing, but adds ramp-up latency. |
…minExecutors / …maxExecutors / …initialExecutors | Bounds and starting point | 0 / infinity / = min | Inspect when short jobs spend much of their life ramping, or when cost is unbounded. Helps: raise initial for short jobs, cap max for cost. Hides a skewed tail that keeps the cluster large for two tasks. |
…schedulerBacklogTimeout | Backlog duration before requesting more executors | 1s | Inspect rarely. Helps lengthened on bursty workloads that over-request. Hides nothing. |
…executorIdleTimeout | Idle duration before releasing an executor | 60s | Inspect when paying for long idle tails. Helps lowered for cost, raised when executors thrash. Hides the tail's real cause. |
…shuffleTracking.enabled | Keep executors that still hold needed shuffle data | false | Inspect when dynamic allocation causes fetch failures. Helps where no external shuffle service exists. Hides nothing. |
Networking and shuffle transport
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.network.timeout | Default network timeout, including heartbeat detection | 120s | Inspect on heartbeat losses. Helps in genuinely high-latency environments. Hides long GC pauses and unhealthy hosts — the classic "larger waiting room". |
spark.executor.heartbeatInterval | Executor liveness and metrics reporting cadence | 10s | Inspect only alongside the timeout. Helps almost never. Hides nothing; must stay well below the timeout. |
spark.shuffle.io.maxRetries / spark.shuffle.io.retryWait | Fetch retry policy | 3 / 5s | Inspect on fetch failures. Helps for genuinely transient loss. Hides a dead executor — retrying a block whose owner is gone only delays the failure. |
spark.reducer.maxSizeInFlight | Concurrent shuffle fetch volume per reducer | 48m | Inspect on reduce-side memory pressure. Helps lowered to relieve memory, raised on fast networks. Hides oversized partitions. |
spark.task.maxFailures | Task attempts before the stage fails | 4 | Inspect when failures recur. Helps essentially never. Hides a deterministic bug, and pays for it four more times. |
spark.stage.maxConsecutiveAttempts | Stage retries after fetch failures | 4 | Inspect during retry storms. Helps essentially never. Hides the first executor death, which is the whole story. |
Speculation
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.speculation | Duplicate slow tasks onto other executors | false frequently enabled by platforms | Inspect when stragglers are host-related. Helps against slow hardware and noisy neighbours. Hides data skew — and is unsafe for non-idempotent side effects. |
spark.speculation.multiplier / …quantile / …interval | How much slower, after what fraction complete, checked how often | 1.5 / 0.75 / 100ms | Inspect if speculation fires too eagerly or too late. Helps tuned to your task-duration variance. Hides nothing, but aggressive settings waste slots. |
File reads and writes
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.sql.files.openCostInBytes | Modelled cost of opening a file, used when packing small files | 4 MB | Inspect on small-file layouts. Helps raised on high-latency object storage — Spark packs more files per task. Hides the small-file problem itself; listing cost is untouched. |
spark.sql.files.minPartitionNum / …maxPartitionNum | Floor/ceiling on read partitions availability varies | unset | Inspect when byte-based splitting gives absurd task counts. Helps as a guard rail. Hides the underlying layout. |
spark.sql.sources.parallelPartitionDiscovery.threshold / …parallelism | When and how widely to distribute directory listing | 32 / 10000 | Inspect when there is a long gap before the first task. Helps on tables with very many partitions. Hides excessive partition cardinality, which is the real fix. |
spark.sql.sources.v2.bucketing.enabled | Enables storage-partition-aware planning for V2 sources | false | Inspect when you expect a shuffle-free join and see exchanges. Helps only with a connector that reports partitioning and compatible layouts on both sides. Hides nothing. |
Statistics, event logging and driver protection
| Property | Purpose | Apache default | When to inspect · when changing helps · when it merely hides |
|---|---|---|---|
spark.sql.cbo.enabled / spark.sql.cbo.joinReorder.enabled | Cost-based optimization and join reordering | false / false | Inspect on multi-way joins with poor ordering. Helps only when column statistics exist. Hides nothing — without statistics it changes nothing at all. |
spark.eventLog.enabled / spark.eventLog.dir | Durable record of the run for the History Server | false / — usually enabled by platforms | Inspect today, not during an incident. Helps by making every past run reproducible. Hides nothing; its absence is what hides everything. |
spark.sql.ansi.enabled | ANSI SQL semantics: strict casts, arithmetic overflow errors enabled by default in Spark 4.0; disabled in 3.x | see your version | Inspect during a 3.x → 4.x migration. Helps catch silent data corruption from bad casts. Hides nothing — but it turns previously-silent NULLs into failures, which is a migration event to plan for. |
spark.serializer / spark.kryoserializer.buffer.max | Object serialization and its buffer ceiling | verify in the Environment tab | Inspect on serialization failures. Helps raised when one object is legitimately large. Hides a data-model problem — ask why a single object is multiple megabytes. |
Observability before the incident.
Everything in this handbook is faster if the numbers already exist. The difference between a two-hour investigation and a two-minute one is almost never skill — it is whether someone captured twenty numbers per run for the last ninety days.
The capture list
Per run, one row, stored somewhere queryable:
| Group | Fields | Source |
|---|---|---|
| Identity | application id, pipeline id, run id, Spark version, cluster/config profile, git commit | Orchestrator + SparkContext |
| Volume | input rows, input bytes, output rows, output bytes, input file count, output file count | Your job code, and the SQL tab's scan/write metrics |
| Shape | task count, stage count, task p50/p95/max duration, max ÷ p50 ratio for the top stages | REST API taskSummary |
| Movement | shuffle read bytes, shuffle write bytes, spill memory, spill disk | REST API stages |
| Health | GC time, executor count over time, failed tasks, retried tasks, executor losses, stage attempts | REST API + event log |
| Cost | executor-hours, cluster minutes including startup, estimated cost, cost per TB | Orchestrator + platform billing Platform-dependent |
| Plan | physical plan fingerprint (normalised hash) | Captured from the query execution at runtime |
# A minimal per-run record. Everything else is an elaboration of this.
from pyspark.sql import SparkSession
import hashlib, json, time
spark = SparkSession.builder.getOrCreate()
sc = spark.sparkContext
t0 = time.time()
result = build_output(spark) # your pipeline
in_rows, out_rows = source_df.count(), result.count() # if affordable; else use metrics
result.write.mode("overwrite").saveAsTable("marts.daily_revenue")
plan = result._jdf.queryExecution().executedPlan().toString()
# strip volatile bits before hashing so the fingerprint is stable run to run
plan_fp = hashlib.sha256(
"\n".join(l for l in plan.splitlines()
if "Statistics" not in l and "#" not in l).encode()
).hexdigest()[:16]
record = {
"pipeline": "daily_revenue",
"run_id": RUN_ID, # from the orchestrator
"app_id": sc.applicationId,
"spark": sc.version,
"input_rows": in_rows,
"output_rows": out_rows,
"fanout": round(out_rows / max(in_rows, 1), 4),
"runtime_s": round(time.time() - t0, 1),
"plan_fp": plan_fp,
}
emit_metrics(record) # to a table, or your metrics system
That single record answers "did the data change, did the plan change, did the shape of the output change" for every future incident. The stage-level detail can be enriched afterwards from the REST API, keyed on app_id.
The best Spark debugging session is the one you never need.
Everything above is remediation. This part is the part that reduces how often you need it — and it is the part that distinguishes a senior engineer from a fast one.
The prevention programme
Baselines
- Every pipeline has a recorded median runtime, input volume, shuffle volume and executor-hours
- Kept for at least 90 days, per weekday
- Visible on a dashboard nobody has to ask for
Regression alerts
- Runtime > 1.5× rolling median
- Executor-hours rising while runtime is flat
- Any stage attempt > 1
- Any executor loss
- Alerts on ratios, never absolutes
Data-volume alerts
- Input bytes vs same weekday last week
- Bytes per row (record-width drift)
- Output ÷ input rows (fan-out)
- These three catch most "Spark is slow" pages before they are pages
File-count alerts
- Average file size per table partition
- Total object count per table
- Partition-directory count, with a hard ceiling
Skew checks
- Top-key share of rows for every join key
- Alert when any single value exceeds a set share
- max ÷ p50 task duration per critical stage, trended
Duplicate-key checks
- Uniqueness test on every dimension key, in CI and at runtime
- Fail the job rather than publish 40× output
- This is a correctness control that happens to be a performance control
Plan-regression awareness
- Normalised physical-plan fingerprint per run
- Alert on change with no deploy
- CI check: partitioned sources must have non-empty
PartitionFilters
Statistics maintenance
ANALYZE TABLE … COMPUTE STATISTICSon a schedule for joined tables- Refresh after any table maintenance that resets them
- Prefer good statistics over join hints, always
SLOs
- State the deadline the consumer actually needs
- Track headroom, not just success
- Shrinking unused headroom is a legitimate optimization
Performance tests
- Run representative volume before merge, not a 1% sample
- Compare plans, not just runtimes
- Fail the build on shuffle-bytes regressions
Resource and cost budgets
- A cap on
maxExecutorsper pipeline - Cost per TB tracked and expected to fall
- Performance PRs report executor-hours alongside runtime
Postmortems & runbooks
- Every incident produces one detection improvement
- Every non-default config carries a reason and an expiry
- Runbooks include "what not to do" — the section nobody writes
Notice how many of the twenty case files would have been a dashboard alert rather than an incident: the sentinel key that grew, the dimension that duplicated, the writer that changed file sizes, the source that reprocessed history, the statistics that were reset, the hint that rotted, the cache that was never unpersisted, the cluster that tripled in cost.
Every one of those is detectable from numbers that already exist inside Spark. The engineering work is not clever — it is the discipline of capturing them, and then trusting a ratio more than a memory.
Print these. Pin them.
1 · The one-page decision tree
2 · The Spark UI cheat sheet
What to look at, for what symptom, and what a bad value means.
| Symptom / question | Where to look | Metric | Interpretation |
|---|---|---|---|
| Is this even a Spark problem? | Orchestrator | Queued vs started vs ended | Pipeline time minus Spark time is someone else's problem |
| Where is the time going? | Jobs tab → Stages | Stage duration as a share of job | One stage over ~60% is the incident; ignore the rest |
| Skew or not? | Stage → Summary Metrics | Duration and Shuffle Read at p50 vs max | Both high → skew. Duration high, read normal → straggler |
| Are partitions the right size? | Stage → Summary Metrics | Spill (memory), Spill (disk) | Any spill at scale → partitions exceed per-task execution memory |
| Is memory healthy? | Executors tab | Peak JVM / execution / storage memory | Peak near budget → spill imminent; heap low with kills → non-heap problem |
| Is GC hurting? | Executors tab | GC Time ÷ Task Time | Rising fraction, or high absolute → allocation or cache pressure |
| Compute or wait? | Stage → Summary Metrics | CPU time ÷ duration; Shuffle Read Blocked Time | CPU ≈ duration → compute-bound. High blocked → network / fetch |
| Is the driver the bottleneck? | Jobs timeline + Executors | Gaps with no active stage; active tasks ≪ slots | Listing, plan compilation, commit, or too many tiny jobs |
| Are we reading too much? | SQL tab → scan node | Input size, files read, partitions read, PartitionFilters, ReadSchema | Empty PartitionFilters → pruning lost. Wide ReadSchema → SELECT * |
| Are we shuffling too much? | Stage list | Shuffle write ÷ input bytes | Ratio ≫ 1 → the plan is moving more than it should |
| Which join did we get? | SQL tab (not EXPLAIN) | Join node type + build-side size | AQE may have changed it; only the final plan is authoritative |
| Did AQE act? | SQL tab | AQEShuffleRead node metrics | Shows coalescing and skew-split decisions with counts |
| Did we fan out? | SQL tab | Output rows on the join / Generate node vs its inputs | A clean multiple → duplicate keys. A wild multiple → explode |
| Is anything failing quietly? | Stages tab | Failed tasks, stage attempt number | Attempt > 1 means work was paid for twice |
| Are executors dying? | Executors tab | Removed executors and removal reasons | Earliest death is the story; everything after is a consequence |
| Is cache helping? | Storage tab | Fraction cached, size in memory, size on disk | Below 100% cached means eviction and recomputation |
| What did we actually run with? | Environment tab | All effective configuration | The only authority on defaults. Settles every argument |
| How do I automate all this? | REST API | /api/v1/applications/… | Same numbers, as JSON. This is how baselines get built |
3 · The production runbook
Before changing anything, collect these thirteen
Paste into the incident channel. Every one is obtainable in under five minutes, and without them you cannot prove any fix worked.
1 · Application ID
- Plus the pipeline and run id
- Without it, no History Server lookup later
2 · Runtime vs history
- This run, and the median for this weekday
- Split pipeline time from Spark time
3 · Input volume
- Bytes, rows, file count
- Compared to the same weekday last week
4 · Output volume
- Bytes, rows, file count
- Output ÷ input rows — the fan-out check
5 · Slowest stage
- Stage id and its share of wall clock
- Its position in the plan
6 · Task p50 / p95 / max
- Duration and shuffle read
- The max ÷ p50 ratio for both
7 · Shuffle read / write
- Totals, and as a ratio to input bytes
8 · Spill
- Memory and disk, per stage
- Zero is the target, not "low"
9 · GC
- GC time ÷ task time, per executor
- Look for the worst executor, not the average
10 · Executor failures
- Count, timestamps, exit codes
- The earliest one, specifically
11 · Driver & executor logs
- Full
Caused bychain - The failing executor's own log, not just the driver's
12 · Physical plan
- From the SQL tab, not
EXPLAIN - Diffed against the last good run
13 · Active configuration
- Environment tab, in full
- Especially anything non-default and undocumented
Then, and only then
- Name the family — orchestration, data, plan, distribution, memory, CPU, I/O, infrastructure, cost.
- Write a falsifiable hypothesis naming a mechanism, not a resource.
- Predict which metric will move, and by roughly how much.
- Change one variable.
- Measure the same metrics you collected above — no others.
- Confirm the mechanism, not just the runtime. Spill went to zero. Max ÷ median fell. Shuffle bytes dropped. Input bytes dropped.
- Record executor-hours alongside runtime. A faster job that costs three times as much is a decision, not a win.
- Document what you changed and which measurement justified it.
- Add the detector that would have caught this before the page.
UI=$SPARK_HISTORY_URL # or the live driver UI
APP=application_1724700000000_04412
curl -s "$UI/api/v1/applications/$APP" # runtime, attempts
curl -s "$UI/api/v1/applications/$APP/jobs" # job/stage structure
curl -s "$UI/api/v1/applications/$APP/stages" # per-stage input, shuffle, spill
curl -s "$UI/api/v1/applications/$APP/executors" # GC, task time, failures, losses
curl -s "$UI/api/v1/applications/$APP/sql" # plans and node metrics
curl -s "$UI/api/v1/applications/$APP/environment" # what it ACTUALLY ran with
# the single most valuable call — task quantiles for the dominant stage:
curl -s "$UI/api/v1/applications/$APP/stages/14/0/taskSummary?quantiles=0.5,0.75,0.95,0.99,1.0"
Wrap those seven calls in a script, run it at the start of every incident, and paste the output. Ten minutes of scripting, saved on every incident for the rest of the pipeline's life.
References & version notes.
This handbook states defaults and behaviours throughout. Every one of them should be checkable against a primary source, and none of them should be trusted over what your own run reports. This appendix is the map from a question to the Apache Spark page that answers it.
1 · /docs/latest/ follows the newest release, not yours. A default you read there may not be the default your cluster ran. Every path below also exists under a pinned version — swap latest for your release (for example /docs/3.5.1/configuration.html) and read that instead. spark.version or the Environment tab tells you which.
2 · The documentation is the authority on what Spark ships; your Environment tab is the authority on what your job used. Where they disagree, your platform overrode something. That is not a contradiction to resolve — it is the finding.
Where each part of this handbook gets its facts
| The question | Apache Spark page | Covers, and which parts here rely on it |
|---|---|---|
| What is this property and what is its default? | Configuration | Every application property: memory, cores, overhead, networking, shuffle transport, speculation, event logging, serialization. The backbone of Part XXVIII, and of the memory settings in Part V and the network settings in Part XVIII. |
| How do the SQL-side knobs and AQE behave? | SQL Performance Tuning | Shuffle partitions, the broadcast threshold, Adaptive Query Execution — coalescing, skew-join handling and its two conditions, runtime join conversion — plus join hints. The primary source for Parts VII, VIII, IX and XIV. |
| What do the Spark UI tabs and columns mean? | Web UI | Jobs, Stages, Tasks, Storage, Environment, Executors and SQL tabs, and the summary-metric columns. The reference behind Part III and the cheat sheet. |
| How do I get these numbers as JSON, and what metrics exist? | Monitoring and Instrumentation | Event logs, the History Server, the REST API endpoints used throughout this page, executor metrics, and the metrics sinks. The basis of Part XXIX and every curl in the runbook. |
| How does Spark actually use memory, and what about GC? | Tuning Spark | Memory management and the unified execution/storage model, serialization, data locality, and the GC discussion. Underpins Parts V and VI. |
| How does dynamic allocation decide? | Job Scheduling | Dynamic resource allocation, the request and removal policies, and the shuffle-data problem that shuffle tracking and an external shuffle service exist to solve. Part XVI, and the decommissioning discussion in Part XVIII. |
| Who killed my container, and why? | Running on YARN · Running on Kubernetes | Cluster-manager-specific memory accounting, container sizing and the kill semantics behind exit 137 and 143. Essential context for Parts IV and V — this is where "container" stops being an abstraction. |
| How do I read a plan? | EXPLAIN | The EXPLAIN modes — extended, formatted, cost — and what each prints. Part III's plan-reading section. |
| How do I fix a bad join choice? | ANALYZE TABLE · Join & partitioning hints | Table and column statistics, and the BROADCAST / MERGE / SHUFFLE_HASH / REPARTITION / REBALANCE hints. Parts IX, XIII and XX. |
| How does the Parquet reader prune and push down? | Parquet Files | Columnar reads, partition discovery, schema merging and pushdown behaviour. Parts XII and XIII — and the caveat that pruning behaviour is source-dependent. |
| Is this a DataFrame setting or an RDD setting? | RDD Programming Guide | Where spark.default.parallelism applies, and why it does not govern SQL shuffles. The distinction drawn in Parts VIII and XXVIII. |
| What changed between my version and the next one? | SQL Migration Guide · Core Migration Guide | The authoritative list of behaviour changes per release — including default flips. Read this before believing any Version-dependent claim on this page. |
| What is new in Spark 4? | Spark 4.0.0 release notes | The 4.x baseline this handbook is written against. Release notes for other versions follow the same URL pattern. |
| PySpark specifics | PySpark documentation | Arrow-based conversion, pandas UDF types and their batching, and the Python worker model behind Parts XI and V. |
Version notes — what this handbook assumes
| Area | What this page says | The version caveat |
|---|---|---|
| AQE on by default | Treated as on throughout, and the reason partition tuning is a size target rather than a count | True from 3.2.0. In 3.0 and 3.1 AQE existed but was off by default — on those versions the older fixed-partition-count advice still applies. |
| AQE skew thresholds | Described by mechanism — a relative factor and an absolute byte threshold, both of which must be exceeded | The default values have moved across the 3.x line, which is why no number is quoted here. Read them from your Environment tab. |
REBALANCE hint | Recommended for even output sizing | Available from 3.3. On earlier versions use repartition and accept that you are choosing the number yourself. |
| Storage Partition Join | Presented as conditional and off by default, behind the V2 bucketing configuration family | Introduced in the 3.x line and extended since, including in 4.x. The set of supported cases — subset join keys, partially clustered distribution — differs by release, and your connector must report partitioning at all. Part XIV states this; the migration guides are where you confirm it. |
| ANSI SQL mode | Flagged as a migration event | Enabled by default in Spark 4.0, disabled by default in 3.x. It converts some previously-silent NULLs into errors — plan for it rather than discovering it. |
| Structured JSON logging | Mentioned as a 4.x direction that makes logs queryable | Whether it is on by default, and the exact field names, vary by release and distribution — and many platforms replace the logging configuration entirely. Check what your build emits before writing parsers. |
spark.serializer | Deliberately not stated | Read it from the Environment tab. This handbook does not quote it because the answer has not been stable enough to quote. |
| Arrow for PySpark conversion | Described by effect, not by default value | The default for spark.sql.execution.arrow.pyspark.enabled is version-dependent. Efficiency is not capacity either way — see Part X. |
# What your job ACTUALLY ran with. Outranks this handbook, the Apache docs,
# your platform's documentation, and whatever anyone remembers.
curl -s "$SPARK_UI/api/v1/applications/$APP_ID/environment" | less
# In a session, the same answer:
spark.conf.get("spark.sql.adaptive.enabled")
spark.sparkContext.getConf().getAll()
Everything in Part XXVIII is a starting point for a conversation. That endpoint is the end of one.
Two kinds of number appear on this page and they carry very different weight. Documented defaults — 200 shuffle partitions, 128 MB max partition bytes, 10 MB broadcast threshold, 1 g driver result size, 120 s network timeout — are checkable in the Configuration and SQL Performance Tuning pages above, and they are labelled Apache Spark default wherever they appear.
Every other number is illustrative. The task quantiles in the annotated stage page, the metrics in all twenty case files, the before-and-after tables in Part XXIV — those are composed to be internally consistent and to demonstrate a mechanism. They are not measurements from a specific cluster and should never be cited as benchmarks. Where a number is a starting point rather than a fact it carries Workload-dependent heuristic, and the surrounding text says which metric should replace it.
Don't tune Spark by folklore. Tune it by evidence.
Almost everything in this handbook reduces to a refusal: the refusal to change a configuration before you can say which level of the stack the problem lives at, which metric proves it, and which number will move if you are right.
That refusal is unglamorous. It is also the difference between a pipeline that gets quietly better over three years and one that accretes a config block nobody dares touch, a runbook that says "try more memory", and a cost curve that only ever goes up.
Spark will tell you almost everything you need. The UI tells you where. The logs tell you why. The event log remembers it after the cluster is gone. The REST API will hand it to you as JSON so you never have to remember it yourself. The only part Spark cannot supply is the discipline to look before you prescribe.
- Find where the time or failure occurs before you touch a configuration. Level first, cause second, fix third.
- Compare max against median. That single ratio separates skew from under-partitioning from stragglers, and those three have almost no fixes in common.
- Capture volume and plan fingerprints per run. Most "Spark is slow" incidents are data incidents, and this turns them into a one-query answer.
- Report executor-hours next to runtime. Fastest job ≠ best job, and the only way anyone learns that is if both numbers are on the same dashboard.