Skip to content
▸ SPARK · debugging & performance engineering · evidence before configuration

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
🧠 How to read this handbook

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.

§ Part I · the mental model

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.

THE INVESTIGATIVE CHAIN causes cascade upward — find the layer where the cost ORIGINATES Pipeline / Scheduler Airflow · Dagster · Jobs API · Glue trigger — queue, retries, upstream waits Spark Application one SparkSession · one driver · N executors · one event log Jobs one per action — count, write, collect, save Stages split at every shuffle boundary — this is where time concentrates Tasks one per partition — the distribution here is the single richest signal Executors JVM + container · slots · heap · local disk · lifetime CPU · Memory · Disk · Network the four physical resources — one of them is always the ceiling Data volume · key distribution · record width · file layout · nulls SQL / Code the plan you asked for — and the plan Catalyst actually produced SYMPTOMS TRAVEL UP · CAUSES LIVE DOWN where people look where the answer usually is
Diagram 1 · The investigative chain. Nine levels. Your first task is never "which config" — it is "which level".
🚨 The single most expensive habit in data engineering

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 DISCIPLINED LOOP SYMPTOM what the alert said EVIDENCE UI · logs · metrics BOTTLENECK which stage, which level ROOT CAUSE the mechanism, named FIX one variable VALIDATE measure · compare if not fixed: back to the evidence, with one more measurement than before ▸ THE FOLKLORE LOOP Slow job no evidence gathered Increase memory the reflex Retry 40 minutes gone Pray non-deterministic cost doubles · runtime unchanged · nobody learned anything
Diagram 2 · Two loops. The top one terminates. The bottom one is a subscription service.

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

🧠 The whole handbook, compressed

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.

🎯 Interview insight

"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."
§ Part II · triage

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.

▸ THE FIRST FIVE MINUTES Alert fires / someone asks note the time. start a scratch doc. Q1 · Did the application start? is there an application ID and a driver? NO YES It never became a Spark problem ▸ orchestrator / scheduler failure — task never ran ▸ cluster provisioning — quota, capacity, spot starvation ▸ permissions / credentials — IAM, token expiry, bucket policy ▸ dependency or JAR — missing, conflicting, wrong Scala version ▸ Python environment — wheel, venv, interpreter mismatch ▸ driver startup / init failure — code that runs before the session ▸ bad configuration — an invalid value rejected at submit the Spark UI has nothing for you here. go to the scheduler log and the cluster-manager log. Q2 · What state is it in? RUNNING but slow → 13 questions start at the active stage FAILED exception thrown → 14 questions driver or executor? COMPLETED but slow → 13 deltas compare to the baseline Before you leave triage, capture five things 1 · application ID 2 · start / end timestamps 3 · input bytes 4 · the slowest stage id 5 · the historical median runtime without these you cannot prove any fix worked.
Diagram 3 · The triage tree. Three questions route you into one of four completely different investigations.
🔎 Look here first — "did it start?" is not a trivial question

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.

▸ RUNNING BUT SLOW — the branch that matters Is ONE stage dominating wall clock? NO — time spread over many stages YES — one stage owns the clock Tasks are PENDING, not running ▸ too many small jobs / actions ▸ scheduler or driver overhead ▸ millions of tiny tasks ▸ file listing dominating ▸ repeated re-computation ▸ a loop in the driver code → Parts X, XII, XV Open the task table. Sort by duration. max ≫ median → skew or straggler max ≈ median, all huge → under-partitioned high spill → memory pressure high GC % → allocation pressure high fetch wait → shuffle / network → Parts VII, VIII, XVII, XIX ▸ not enough executors yet ▸ dynamic allocation still ramping ▸ cluster-manager queue / quota ▸ executors dying and respawning ▸ another job holding the pool ▸ locality wait before falling back → Parts XVI, XVIII CPU high or low? memory pressure? executors disappearing? input or output unexpectedly huge? — each answer prunes one whole branch.
Diagram 4 · Running-but-slow sub-tree. Three shapes of "slow", three unrelated cause families.

The thirteen questions, in priority order

  1. Is one stage dominating? If 80% of wall clock is in one stage, that stage is the incident. Everything else is noise.
  2. Are most tasks finished except a few? The "stuck at 99%" shape. Skew or straggler — Parts VII and XVII separate them.
  3. Are all tasks uniformly slow? Under-partitioning, an expensive per-row operation, or an under-provisioned cluster. Max ≈ median is the signature.
  4. 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.
  5. Are tasks pending? Pending with idle capacity is a placement problem; pending with no capacity is a sizing problem.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. Is spill high? Any disk spill in the gigabytes-per-task range means partitions do not fit in execution memory.
  11. Is input unexpectedly huge? The most under-checked question in this list. Part XXI exists because of it.
  12. Is output unexpectedly huge? A join that fanned out. Output rows ≫ input rows is a cardinality bug, not a performance bug.
  13. 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.

🔎 The fourteen failure questions
  • 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.maxResultSize exceeded — someone called collect(). 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.

DimensionWhat a normal change looks likeWhat a red flag looks like
Runtime±15% day to day3× with input up only 5%
Input volumeGrows with the businessDoubles overnight — check for a reprocessed source
Output volumeTracks inputGrows faster than input — a join fanned out
Executor-hoursTracks runtime × cluster sizeRises while runtime falls — you bought speed with money
Shuffle bytesTracks input4× while input is flat — plan changed, or a broadcast stopped happening
CPU timeTracks records processedFlat while runtime triples — you are waiting, not computing
GC timeSmall, stable fractionRising fraction — cache pressure or object churn
SpillIdeally zeroAppears where it never used to — partitions outgrew execution memory
File countStable per partition10× more files — an upstream writer changed
Task countStableExplodes — partition sizing or file layout changed
Query planIdentical fingerprintA join strategy flipped — stats went stale
Cluster sizeWhatever you asked forSmaller than requested — quota or spot reclamation
CostTracks executor-hoursRising while runtime is flat — retries, idle executors, or re-reads
⚠️ Don't do this

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.

🎯 Interview insight

"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."
§ Part III · the surfaces

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.

▸ WHERE THE EVIDENCE LIVES Orchestrator Airflow · Dagster Jobs API · Glue submit DRIVER plan · schedule · track broadcast · collect single point of coordination launches tasks Executor 1 Executor 2 Executor N Executor logs stdout · stderr · GC log Python worker output the real exception is here Local disk (scratch) shuffle files · spill · cached blocks Spark UI (live) jobs · stages · SQL · executors Event log the durable record of the run History Server the UI, after the fact REST API · /api/v1/applications/<id>/… the same numbers as the UI, in JSON — this is how you automate baselines Cluster manager YARN · Kubernetes Standalone · Mesos-era Storage S3 · GCS · ADLS · HDFS table format metadata Monitoring / metrics system Spark metrics sink → Prometheus / StatsD / your platform's agent host metrics: CPU, memory, disk I/O, network — the layer Spark cannot see Run metadata params · retries · queue time Rule of thumb — UI answers "where and how much" · logs answer "why it threw" · cluster manager answers "who killed it" · monitoring answers "was the machine healthy" Event logs are the only surface that survives the cluster. If eventLog is off, every incident becomes unreproducible archaeology. The REST API returns the same data the UI renders — including per-stage task quantiles. Automate against it, don't screenshot the UI.
Diagram 5 · The eight debugging surfaces. Each answers a different question. Looking at the wrong one is the most common way to lose an hour.

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.

▸ ANATOMY OF A 90-MINUTE "SPARK JOB" Waiting for upstream 35 min · sensor / dependency Provision 10 min SPARK EXECUTION 40 min · the only part the Spark UI can see publish 00:00 00:35 00:45 01:25 01:30 tuning Spark improves NONE of this tuning Spark improves ONLY this — 44% of the pipeline If you halve the Spark stage, the pipeline goes 90 → 70 minutes, not 90 → 45. Amdahl's law applies to pipelines too. Know your denominator before you promise an SLA improvement.
Diagram 6 · Pipeline time vs Spark time. A 90-minute pipeline containing 40 minutes of Spark. Optimise the wrong segment and you will work hard for a 6% win.
🔎 What to pull from the orchestrator, every time
  • 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, a show() 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.
🧠 Why stage boundaries are where the truth is

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.

▸ CONCEPTUAL STAGE PAGE — what each column is for illustrative numbers for one pathological stage; column names follow the Spark stage summary-metrics table METRIC MIN 25th MEDIAN 75th MAX Duration GC Time Shuffle Read Size Shuffle Read Records Spill (disk) Scheduler Delay Shuffle Read Blocked Time 18 s22 s26 s31 s 0.4 s0.6 s0.8 s1.1 s 240 MB300 MB340 MB390 MB 3.1 M3.8 M4.2 M4.9 M 0 B0 B0 B0 B 3 ms4 ms5 ms7 ms 0.1 s0.2 s0.3 s0.4 s 34 min 6.2 min 41 GB 512 M 28 GB 9 ms 0.6 s The max/median ratio IS the diagnosis duration 34 min / 26 s ≈ 78× shuffle 41 GB / 340 MB ≈ 120× records 512 M / 4.2 M ≈ 122× duration scales with DATA → this is skew, not a slow machine. (Part VII) If duration were high but data flat… → high scheduler delay = placement / queueing → high blocked time = fetch / network → high GC % = allocation pressure → none of the above = bad node / straggler ▸ WHY PERCENTILES BEAT AVERAGES Average task duration for the stage above: (5 000 × 26 s + 34 min) / 5 001 ≈ 26.4 s. The average is 26.4 s. The stage takes 34 minutes. The average is not wrong — it is irrelevant. Stage wall clock is governed by the LAST task to finish, so the max is the number that determines your runtime, the median tells you what "normal" costs, and the ratio between them tells you which failure mode you are in. p75 matters too: max ≫ p75 ≈ median means one or two pathological partitions; p75 already elevated means a broad tail, which is usually under-partitioning or resource contention rather than a single hot key. Practical rule: read MEDIAN to size the work, read MAX to explain the runtime, read the RATIO to name the cause.
Diagram 7 · The annotated stage page. Conceptual rendering, illustrative numbers. The columns and the comparisons are what matter.

The metrics worth learning by name

MetricWhat it actually measuresWhat a bad value tells you
DurationTask execution wall clockNothing on its own — always read it against data volume
Input Size / RecordsBytes and rows read from a data sourceBigger than expected → pruning failed or the source grew
Output Size / RecordsBytes and rows writtenGrows faster than input → cardinality explosion (Part IX)
Shuffle Read Size / RecordsData fetched from other executors' map outputMax ≫ median → skew. Total ≫ input → the plan moves too much
Shuffle Write Size / RecordsData written for downstream stagesHuge write on a small stage → an unnecessary repartition or exchange
Spill (memory) / Spill (disk)Data evicted from execution memory to diskNon-zero at scale → partitions do not fit; the honest memory signal
GC TimeJVM pause time attributed to the taskHigh fraction of duration → allocation pressure (Part VI)
Scheduler DelayTime between task being sent and starting, plus result returnHigh → scheduling/placement pressure, or a driver too busy to dispatch
Task Deserialization TimeUnpacking the task closure on the executorHigh → a fat closure; you are shipping data inside the task
Result Serialization Time / Getting Result TimePacking and returning results to the driverHigh → returning too much to the driver (Part X)
Shuffle Read Blocked TimeTime a task spent waiting for remote blocksHigh → network, remote executor pressure, or oversized blocks
Peak Execution MemoryHigh-water mark of execution memory for the taskClose to the budget → spill is imminent
Locality LevelPROCESS_LOCALNODE_LOCALRACK_LOCALANYMostly ANY on an HDFS-style cluster → placement lost; on object storage this is normal and not a problem
Failed / Killed tasksAttempts that did not completeAny recurring failure is a root cause you have not found yet
📊 Verify — pull the quantiles instead of squinting at the UI

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.

▸ SIX TASK DISTRIBUTIONS 1 · HEALTHY max ≈ 1.3 × median · no spill · GC < 5% nothing to fix here. go look at another stage. 2 · SKEW max ≫ median AND max input ≫ median input duration tracks data. → Part VII 3 · UNIFORM UNDER-PARTITIONING max ≈ median, but EVERY task is huge · heavy spill too few partitions for the data. → Part VIII 4 · BAD NODE / NOISY NEIGHBOUR slow tasks share ONE executor id · inputs are normal group by executor, not by task. → Part XVII 5 · GC PRESSURE tasks get slower over the stage · dark band = GC time the executor is aging, not the data. → Part VI 6 · FETCH / NETWORK WAIT blue = blocked time · every task waits, none computes low CPU, high wait. → Parts XVIII, XIX Two more you will meet: CPU-BOUND — uniform, high CPU time ≈ duration, tiny I/O (a UDF or a regex, Part XI) · I/O-BOUND — uniform, CPU time far below duration, high read/write wait (object-store latency or tiny files, Part XII). The discriminator is always CPU time ÷ duration.
Diagram 8 · Six task distributions. Learn these shapes and most Spark incidents become a two-minute glance.

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.

▸ FROM CODE TO WHAT ACTUALLY RAN SQL / DataFrame Parsed logical plan syntax only Analyzed logical plan catalog resolved Optimized logical plan pushdown · pruning Physical plan joins chosen here AQE re-plans per stage using real stats Executed plan the ONLY one that ran feedback loop: real shuffle sizes change the next stage's plan ▸ HOW TO SEE EACH ONE EXPLAIN → physical plan only EXPLAIN EXTENDED → parsed, analysed, optimized, physical EXPLAIN FORMATTED → physical plan as a compact tree + per-operator detail block ← read this one EXPLAIN COST → optimized logical plan annotated with statistics (only useful if stats exist) df.explain("formatted") / df.explain("cost") / df.explain(True) → the same, from PySpark and Scala and the SQL tab of the UI → the FINAL plan, with per-operator row counts and timings. Nothing else shows you AQE's decisions.
Diagram 9 · Catalyst, end to end. EXPLAIN shows intent. The SQL tab shows reality.
🚨 The most common plan-reading mistake

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

OperatorWhat it meansWhen it deserves suspicion
Scan parquet / FileScanReading a data sourceCheck number of files read, size of files read, and whether PartitionFilters and PushedFilters are populated. Empty PartitionFilters on a partitioned table = full scan.
FilterA predicate evaluated in-engineA filter that appears above a join, when it could have been pushed below it
ProjectColumn selection / expression evaluationProjecting far more columns than the query needs (the SELECT * tax)
ExchangeA shuffle — data moves across the networkSee the note below. Not automatically bad.
SortOrdering, usually feeding a sort-merge join or a windowA global sort with a single partition; sorts that could be avoided by a different join strategy
HashAggregateGrouping — appears twice, partial then finalOnly the final aggregate present → no partial pre-aggregation, so full rows shuffled
BroadcastExchange + BroadcastHashJoinOne side collected to the driver and shipped to every executorBuild side larger than you think; repeated broadcasts of the same table
SortMergeJoinBoth sides shuffled by key and mergedFine for large-to-large. Suspicious when one side is tiny — stats may be stale
ShuffledHashJoinBoth sides shuffled, one built into a hash mapReasonable when one side fits in memory per partition and sorting is not needed
BroadcastNestedLoopJoinNo usable equality condition — nested loops over a broadcast sideAlways investigate. Usually a missing or non-equi join condition
CartesianProductEvery row against every rowAlmost always a bug. Output rows = left × right
WindowWindowed computation, preceded by a shuffle and sortUnbounded frames, many distinct window specs, or partitioning by a low-cardinality key
Generateexplode() and friendsRow multiplication — check output rows against input rows (Part XX)
BatchEvalPython / ArrowEvalPythonThe Python UDF boundaryRows leave the JVM. BatchEvalPython is row-at-a-time; ArrowEvalPython is vectorised (Part XI)
ReusedExchange / ReusedSubqueryA shuffle or subquery computed once and reusedGood news when present; its absence where you expected it means you are computing something twice
AQEShuffleReadAQE coalescing or splitting shuffle partitions at runtimeIts presence confirms AQE acted; read its metrics to see what it decided
🧠 "Exchange means data movement" — but an Exchange is not automatically bad

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.

🎯 Interview insight

"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, run ANALYZE TABLE … COMPUTE STATISTICS if 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."
✦ ✦ ✦
§ Part IV · logs

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

LogWhere it livesWhat only it can tell you
Driver logDriver stdout/stderr; the orchestrator usually captures itThe final exception, plan compilation, broadcast decisions, scheduler behaviour, DAGScheduler messages, why the job aborted
Executor logsPer-executor stdout/stderr, retrievable from the Executors tab while alive; from the cluster manager afterwardsThe original exception, before it was wrapped and shipped to the driver. Also spill messages, block-manager activity, and fetch errors
Cluster-manager logsYARN NodeManager / ResourceManager, Kubernetes events and pod descriptions, standalone worker logsWho killed the container and why: memory limit exceeded, node drained, preemption, spot reclamation, eviction
JVM GC logOnly if you enabled it via spark.executor.extraJavaOptionsPause durations and frequency, heap occupancy after collection — the difference between "GC is busy" and "the heap is genuinely full"
Python worker outputInterleaved into executor stderrThe Python traceback, native-library crashes, and the memory the worker was using when it died
Event logWritten to spark.eventLog.dir when spark.eventLog.enabled=true Apache default: falseThe complete structured record of the run — every task, every metric — replayable in the History Server long after the cluster is gone
🚨 If event logging is off, you are debugging from memory

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.

▸ HOW ONE BAD PARTITION BECOMES "Job aborted due to stage failure" 1 · TASK FAILS on one executor the real cause is HERE 2 · EXECUTOR REPORTS exception serialised and shipped to driver 3 · TASK RETRIES up to spark.task.maxFailures (Apache default 4) 4 · STAGE ABORTED after repeated attempts (or maxConsecutiveAttempts) 5 · DRIVER RAISES SparkException: Job aborted this is what you see first READ THE CHAIN BACKWARDS — the outermost exception is the least informative one in it ▸ THE TECHNIQUE: read from the bottom, then find the FIRST meaningful "Caused by" org.apache.spark.SparkException: Job aborted due to stage failure: Task 1841 in stage 14.0 failed 4 times… ← outer wrapper, near-zero information Caused by: org.apache.spark.shuffle.FetchFailedException: Failed to connect to ip-10-0-4-91:7337 ← a SYMPTOM: something went away Caused by: java.io.IOException: Connection reset by peer ← still a symptom → now go to the EXECUTOR log on ip-10-0-4-91. That is where the sentence that actually explains the incident is written. Rule: keep descending until the message names a RESOURCE (heap, container limit, disk, file, credential) or a VALUE (a column, a cast, a row). That is your root cause.
Diagram 10 · Failure propagation. Five hops from cause to the message you were paged with.
🔎 A repeatable log-reading procedure
  1. Find the last exception in the driver log. Note the stage and task id.
  2. Walk the Caused by: chain downward. Stop at the first frame that names a resource or a value, not a Spark internal.
  3. 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.
  4. 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.
  5. 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.
  6. 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 logging note

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.
⚠️ Bad fix

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.

🛠 Correct fix

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
⚠️ Bad fix

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.

🛠 Correct fix

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
🛠 How to tell them apart in ninety seconds

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.

⚠️ Bad fix

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.

🛠 Correct fix

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
⚠️ Bad fix

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.

🛠 Correct fix

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 messageWhat it usually meansWhere to verifyDangerous knee-jerk fixCorrect first move
OutOfMemoryError: Java heap spaceOne task's working set exceeded the heapWhich side threw it (driver vs executor); stage task max-vs-median for shuffle readDouble executor memoryDetermine skew vs uniform. If skew, fix the key distribution; if uniform, raise parallelism
Container killed / OOMKilled / exit 137Total container memory exceeded — often non-heapCluster-manager events; PySpark worker memory; off-heap settingsRaise executor.memory inside the same containerFind the non-heap consumer. Cap Python memory or reduce cores per executor
GC overhead limit exceededHeap effectively full of live objectsExecutors tab GC time; cached RDD/DataFrame storageSwitch GC algorithmReduce live set: unpersist caches, shrink partitions, remove object churn
ExecutorLostFailureAn executor disappeared — cause is elsewhereThat executor's log; exit code; cluster-manager eventsIncrease spark.task.maxFailuresRead the lost executor's own log; classify by exit code
FetchFailedExceptionShuffle blocks unreachable — usually a dead executorExecutors tab for deaths near the timestamp; block sizes; host metricsIncrease fetch retries and timeoutsFind why the block source went away; shrink oversized shuffle blocks
Heartbeat timed outExecutor could not answer in timeGC time fraction on that executor; host CPU and diskRaise spark.network.timeoutCheck GC and CPU oversubscription first
NotSerializableExceptionClosure captured a non-serializable objectThe named class in the traceMake everything SerializableCapture locals only; build the object inside mapPartitions
Task of very large sizeData is travelling inside the closureTask deserialization time in stage metricsIgnore the warningBroadcast the lookup, or read it on the executor
Kryo buffer overflowOne object too large to serializeWhat is being collected into a single valueRaise the buffer and move onRaise the buffer and ask why a single object is that large
Python worker crashedWorker process died — memory or native faultExecutor stderr for traceback / segfault; container eventsRetry the jobSeparate memory-kill from segfault from record-specific crash
AnalysisExceptionSchema does not match expectationSource table schema historyAdd a cast to silence itFind the upstream change; add a contract/test so it fails at the source
FileNotFoundException on readFiles changed underneath the queryUpstream write times; table format in useAdd a retry loopREFRESH TABLE for stale cache; fix the overwrite race properly
Permission / credential deniedWrong identity, or a token that expired mid-runFailure timestamp vs job startGrant broader permissionsIf late in the run, fix credential refresh, not the policy
maxResultSize exceededToo much data returned to the driverThe action in the stack traceRaise maxResultSizeStop collecting. Write to storage, aggregate first, or sample
🎯 Interview insight

"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."
✦ ✦ ✦
§ Part V · memory

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.

▸ ONE EXECUTOR CONTAINER, HONESTLY DRAWN CONTAINER / POD MEMORY LIMIT ← what YARN or Kubernetes enforces. Exceed it and you are killed, not exceptioned. JVM HEAP — spark.executor.memory this is the ONLY region that config controls Reserved fixed internal reservation Spark keeps a small slice back before applying memory.fraction UNIFIED MEMORY — spark.memory.fraction Apache default 0.6 of (heap − reserved) EXECUTION joins · aggregations sorts · shuffles runs out → SPILL (not an error) STORAGE cache() · persist() broadcast blocks runs out → EVICT (silent recompute) USER MEMORY your objects UDF state closures data structures Spark internals runs out → OOM (a real exception) Execution and storage BORROW from each other. Execution can evict cached blocks; storage cannot evict execution. spark.memory.storageFraction (Apache default 0.5) only sets the floor storage is guaranteed, not a hard split. JVM NON-HEAP metaspace thread stacks code cache PYTHON WORKERS one process per concurrent task outside the heap NATIVE / OFF-HEAP codecs · Arrow direct buffers memory.offHeap.size MISC OVERHEAD OS · page cache sidecars · agents shuffle buffers these four …are what spark.executor.memoryOverhead covers Apache Spark computes it as a factor of executor memory with a minimum floor — check the exact factor and floor for YOUR version, and check what your platform overrode it to. CONTAINER ≈ executor.memory + memoryOverhead (+ pyspark.memory + offHeap.size where configured) Raising executor.memory alone does NOT create room here. THE FOUR "OUT OF MEMORY" EVENTS ARE DIFFERENT 1 · Executor heap OOM exception thrown · task retried · fix = less data per task 2 · Container / pod kill no exception · process gone · fix = non-heap budget 3 · Driver heap OOM whole app dies · fix = stop collecting / broadcasting 4 · Python worker memory worker killed · looks like an EOFException in the JVM Naming which one you have is 80% of the fix.
Diagram 11 · Executor memory anatomy. spark.executor.memory is one box out of eight.

The five settings, and what each one actually moves

SettingControlsRaise it whenRaising it is wrong when
spark.executor.memoryThe JVM heap of each executorPer-task working set is genuinely large and irreducible; heavy caching is intentionalThe failure was a container kill; the problem is skew; GC pauses are already long
spark.executor.memoryOverheadExtra container memory outside the heapContainer kills with a healthy heap; heavy off-heap/native/shuffle buffer useYou did not also raise the container size — on some managers, overhead comes out of the same budget Platform-dependent
spark.executor.pyspark.memoryA budget for Python worker processes (when set)PySpark jobs where workers are the non-heap consumerThe job is Scala/SQL only — it changes nothing
spark.memory.offHeap.enabled / .sizeOff-heap execution memory Apache default: disabledYou want large execution memory without long GC pauses, and you have sized the container for itYou enabled it without adding container room — you just shrank effective memory
spark.memory.fractionShare of usable heap given to execution+storage Apache default 0.6Almost never — it is one of the last knobs, not one of the firstYou are compensating for user-memory pressure caused by your own objects; fix the objects
spark.memory.storageFractionThe floor of unified memory guaranteed to cache Apache default 0.5You cache deliberately and eviction is measurably hurtingYou are caching things you should not cache (Part XV)
🧠 Why "executor OOM → increase executor memory" is so often wrong

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.

▸ OOM DECISION TREE — answer in this order, never skip a step Q1 · Was an exception thrown, or did a process just vanish? EXCEPTION VANISHED (exit 137 / OOMKilled) Q2 · Driver stack or executor stack? DRIVER collect / toPandas? huge broadcast? millions of tasks? giant plan / metadata? → Part X EXECUTOR HEAP Q3 · max task input ≫ median? YES → skew. Part VII. More memory will NOT fix it. NO → uniform. Raise parallelism or lower cores per executor. Q2 · Is this PySpark? YES — Python workers one worker per concurrent task pandas UDF batch size? whole-partition collection? → cap pyspark.memory, or reduce executor cores NO — JVM non-heap off-heap enabled? native codecs / Arrow? many threads / large stacks? → raise memoryOverhead AND the container size ▸ NINE FIXES THAT BEAT "MORE MEMORY", ROUGHLY IN ORDER OF HOW OFTEN THEY ARE RIGHT 1 · Increase partitions so each task holds less 2 · Remove or isolate the skewed key 3 · Reduce executor cores (more memory per task, free) 4 · Raise memoryOverhead when the kill was non-heap 5 · Delete a collect() / toPandas() 6 · Unpersist a cache that is pinning the heap 7 · Change join strategy so nothing huge is built 8 · Replace a Python UDF with a native expression 9 · Fix pathological records (giant arrays, nested blobs) Raising executor memory is the tenth option, not the first. It is correct only when the per-task working set is genuinely irreducible — and when you have checked that a longer GC pause will not simply move the failure somewhere else.
Diagram 12 · The OOM decision tree. Four different failures wearing the same three letters.
📊 Verify — prove which memory you ran out of
# 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
🎯 Interview insight

"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. Then spark.executor.pyspark.memory to 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."
§ Part VI · garbage collection

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

🔎 GC % = JVM GC Time ÷ Executor Run Time

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 GC PRESSURE LOOKS LIKE ON A CPU TIMELINE HEALTHY EXECUTOR — GC ≈ 4% long stretches of useful work · brief minor collections · heap occupancy returns to baseline every cycle DEGRADING EXECUTOR — GC climbing past 45% heartbeat missed here executor declared lost Green = useful work · Red = collection. The signature is not "GC exists" — it is that pauses grow as the stage progresses. That growth means the live set is growing: cached blocks accumulating, an aggregation building state, or objects being promoted faster than they die. A job that gets steadily slower without the data changing is the classic surface symptom.
Diagram 13 · The GC timeline. Executors do not fail suddenly from GC. They decay.

What actually causes it

ContributorMechanismWhat to do instead of tuning GC
Caching large datasetsCached blocks are long-lived and must be scanned by every major collectionCache less, cache narrower (project columns first), or use a disk-inclusive storage level. Part XV
Oversized heapsMore heap to scan, longer pauses, worse worst casePrefer more executors with moderate heaps over few executors with huge heaps workload-dependent
Object churn in UDFsRow-at-a-time Python or Scala UDFs allocating per recordUse built-in expressions; if you must use Python, use Arrow-based UDFs (Part XI)
Too many cores per executorN concurrent tasks all allocating into one heapReduce cores per executor; run more, smaller executors
Wide rows and nested structuresDeserialised objects far larger than their encoded formProject only needed columns; flatten hot paths; avoid materialising nested blobs
Huge aggregation stateHigh-cardinality GROUP BY building enormous hash mapsPre-aggregate, reduce key cardinality, or use approximate sketches
⚠️ Do not start by changing the GC algorithm

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.

🎯 Interview insight

"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."
✦ ✦ ✦
§ Part VII · skew

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.

▸ SHUFFLE BYTES PER PARTITION NORMAL — stage finishes when the typical task finishes max / median ≈ 1.2 · every task ≈ 340 MB SKEWED — stage finishes when the WORST task finishes max / median ≈ 120 · most tasks 340 MB, six tasks 41 GB 994 tasks done in 26 s. Six tasks take 34 min. Stage takes 34 min. ▸ THE ASCII VERSION, FOR YOUR RUNBOOK normal: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇ skew: ▂▂▂▂▂▂▂▂▂▂████████████████ Adding executors to the skewed picture changes NOTHING. The six big tasks still run one per slot, and the other 994 slots sit idle while you pay for them. Skew is the failure mode scale-out cannot buy its way out of.
Diagram 14 · Healthy vs skewed partitions. The bar that matters is the tallest one.

Nine flavours of skew, and where each comes from

FlavourTypical shapeWhere it hides
Skewed join keysOne or a few key values with vastly more rows on one or both sidesA "power user", a platform account, an internal test tenant
Skewed GROUP BYOne group holds a large fraction of all rowsGrouping by a status, a country, a device type with a dominant value
NULL skewEvery NULL key hashes to the same partitionOptional foreign keys. Note: in an inner equi-join NULL keys never match, so they are shuffled and then discarded — pure waste
Sentinel / default IDs0, -1, 'UNKNOWN', 'N/A', empty stringUpstream systems that refuse to emit NULL. Often the single biggest key in the table
Time-based skewOne date/hour partition far larger than the restBackfills, a launch day, a batch of late-arriving data landing in one bucket
Geographic skewOne region or city dominatingPartitioning or grouping by geography in a business that is concentrated
Tenant / customer skewThe largest customer is 100× the median customerEvery multi-tenant system, always. Plan for it from day one
Explode-created skewOne input row expands into millionsAn array column whose length distribution is heavy-tailed
Storage partition skewOne directory holds most of the files or bytesReading a partitioned table where one partition value dominates

How to prove it, not guess it

🔎 Four escalating levels of evidence

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.

📊 Verify the fix — the three numbers that must move
  1. max/median task duration ratio for the affected stage. If it did not fall, you did not fix skew.
  2. max/median shuffle read. This is the cause; duration is the effect.
  3. 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.
🎯 Interview insight

"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 BY group, and nothing for skew created by an explode downstream. 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 for AQEShuffleRead nodes 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."
§ Part VIII · shuffle

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

▸ WHAT "SHUFFLE" ACTUALLY DOES MAP SIDE — every task writes one block per reduce partition Executor A Executor B Executor C Executor D 1 · partition by key 2 · sort within partition 3 · serialize + compress 4 · write data file 5 · write index file all of this lands on LOCAL DISK, not storage network REDUCE SIDE each reduce task fetches ITS partition's block from EVERY map executor buffers in execution memory spills to disk when full M maps × R reducers = M×R blocks reducer 1 reducer 2 reducer R THE FIVE COSTS OF ONE SHUFFLE CPU — serialize, compress, sort, deserialize MEMORY — map-side buffers, reduce-side buffers DISK — every byte written locally, then read NETWORK — every byte crosses the wire once DURABILITY — map output must survive until consumed, or the map stage re-runs that last one is why executor loss is so expensive ▸ THE PARTITION-COUNT TRADE-OFF — there is no universal right answer, only a curve TOO FEW partitions · each task holds too much → spill to disk · fewer tasks than slots → idle executors · huge shuffle blocks → fetch timeouts · one bad key ruins one enormous task TOO MANY partitions · scheduling overhead per task dominates · M×R shuffle blocks explodes → tiny reads · driver tracks more metadata · output writes produce tiny files THE TARGET IS A SIZE, NOT A COUNT choose so that a partition fits comfortably in per-task execution memory, produces no spill, and gives you at least as many tasks as slots — then let AQE coalesce the tail.
Diagram 15 · Shuffle mechanics and the partition-count trade-off. M maps × R reducers blocks, five simultaneous costs.

The nine shuffle pathologies

PathologyEvidence in the UIWhat it actually costs you
Excessive shuffleShuffle write ≫ input bytes across the jobYou are moving data you could have reduced, filtered or broadcast first
Too few partitionsTask count < slot count; large per-task input; spill non-zeroIdle capacity plus disk I/O you did not need
Too many partitionsEnormous task count; median task duration in the tens of millisecondsScheduling overhead exceeds useful work; driver pressure; tiny output files
Huge shuffle blocksMax shuffle read ≫ median; fetch failures on the biggest tasksTransfers that time out and force map-stage re-execution
Memory spillSpill (memory) non-zeroData serialized out of execution memory — CPU cost before any disk cost
Disk spillSpill (disk) non-zero, often in GBExtra write + read per spilled byte; local disk saturation (Part XIX)
Fetch failuresFetchFailedException; repeated stage attemptsWhole map stages re-executed. The most expensive failure mode in Spark
Network saturationHigh shuffle read blocked time across all tasks; host network metrics pinnedEverything waits; adding executors makes it worse
Local disk pressureExecutor loss with disk-full messages; rising task times as the stage progressesExecutors 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.

SettingWhat it doesApache defaultHow to think about it now
spark.sql.shuffle.partitionsNumber of partitions produced by a shuffle200With 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.enabledMaster switch for adaptive re-planningtrue (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.enabledMerge small post-shuffle partitionstrueThis is what makes a generous initial partition count safe.
spark.sql.adaptive.advisoryPartitionSizeInBytesTarget size AQE aims for when coalescing (and when splitting skewed partitions)64 MBThe 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/thresholdSplit skewed shuffle partitions in joinstrueSee Part VII. Both the relative factor and the absolute threshold must be exceeded.
spark.sql.adaptive.localShuffleReader.enabledRead shuffle output locally after AQE converts a join to broadcasttrueLeave it on; it removes a network hop after a plan change.
spark.default.parallelismDefault partition count for RDD operationstotal cores (cluster-mode)Does not control DataFrame/SQL shuffles. A frequent source of confusion — the SQL path uses spark.sql.shuffle.partitions.
🧠 Why "just use N partitions" cannot be right

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.

🔎 Before → evidence → change → after

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.

🎯 Interview insight

"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 advisoryPartitionSizeInBytes targets, 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."
✦ ✦ ✦
§ Part IX · joins

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.

▸ JOIN STRATEGY MATRIX STRATEGY SHUFFLE SORT MEMORY PROFILE CHOSEN WHEN THE RISK BroadcastHashJoin no exchange on the big side none (build side collected) none build side held ENTIRELY in driver, then in EVERY executor estimated build side under autoBroadcastJoinThreshold driver OOM · broadcast timeout table silently grows past the limit ShuffledHashJoin hash map per partition both sides no one side's PARTITION must fit in execution memory one side much smaller but too big to broadcast; sort not needed OOM if a partition is skewed — it cannot spill as gracefully as sort SortMergeJoin the reliable workhorse both sides both sides streaming — spills to disk rather than failing large ⋈ large on an equality condition slow but rarely fatal. Chosen by mistake when stats are stale BroadcastNestedLoopJoin no usable equality key broadcast none O(left × right) COMPARISONS even if output is small non-equi condition (BETWEEN, <, LIKE) or no condition at all the CPU bomb: 1M × 50k = 50 BILLION comparisons CartesianProduct every row × every row full replication none output rows = |L| × |R| no join condition survived the optimizer almost always a BUG. Seeing this node should stop the review.
Diagram 16 · Join strategy matrix. Two rows are tools. Two rows are alarms.

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.autoBroadcastJoinThreshold Apache 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.broadcastTimeout Apache 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.
⚠️ The broadcast hint is a promise you have to keep

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.

▸ 100M × A DIMENSION TABLE DOES NOT MEAN 100M ROWS OUT orders 100,000,000 rows one row per order ⋈ on customer_id dim_customer — CLEAN exactly 1 row per customer_id the assumption everyone makes 100,000,000 rows 1× fan-out · as designed dim_customer — REALITY SCD2 history not filtered, or a duplicate load: 5 rows per customer_id 500,000,000 rows 5× fan-out · silent EVERYTHING DOWNSTREAM ×5 shuffle bytes ×5 spill ×5 (or from zero to a lot) output files ×5 every SUM in every downstream dashboard is now 5× too high The performance symptom and the correctness bug are the SAME event. A job that suddenly shuffles 5× more data on flat input is telling you your numbers are wrong — not that it needs a bigger cluster. This is why "output rows ÷ input rows" belongs on your dashboard. Test for it in one line: SELECT customer_id, COUNT(*) c FROM dim_customer GROUP BY customer_id HAVING c > 1 LIMIT 10;
Diagram 17 · Join explosion. Cardinality is a data-quality property. The optimizer cannot save you from it.

The eight join failure modes

ModeSymptomHow to catch it
Duplicate keys in a dimensionOutput rows a clean multiple of expected; downstream sums inflatedGROUP BY key HAVING COUNT(*) > 1 on every dimension you join, as a test
Missing predicateCartesianProduct or BroadcastNestedLoopJoin in the planRead the physical plan. Those two node names are alarms, not information
ON 1=1Same 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 joinBETWEEN or range conditions producing nested loopsAdd an equality component (a bucket, a date key) so a hash join becomes possible, then filter
Many-to-manyOutput rows = product of duplicate counts on both sidesDeduplicate one side first, or aggregate to the grain you actually need
Wrong join orderLarge intermediate results before a filter reduces themFilter early; check whether the optimizer pushed predicates down (EXPLAIN FORMATTED)
Stale statisticsA tiny table joined by sort-merge; a huge table broadcastANALYZE TABLE … COMPUTE STATISTICS; check EXPLAIN COST
Over-eager hintsA broadcast hint on a table that grew; driver OOMAssert size in the pipeline; alert on the table's row count
🛠 Statistics, and why they matter more than any join hint
-- 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.

🎯 Interview insight

"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(*) > 1 on 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."
✦ ✦ ✦
§ Part X · the driver

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.

▸ 500 EXECUTORS. ONE DRIVER. … × 500 executors, each with 4 cores = 2,000 concurrent task slots THE DRIVER · holds the DAG and every plan object · tracks metadata for EVERY task · collects and ships EVERY broadcast · receives EVERY result you collect · one JVM · one heap · no failover WHAT OVERWHELMS IT collect() / collectAsList() / toPandas() broadcasting a table that grew millions of tasks → metadata alone fills the heap listing millions of files before the job starts an enormous query plan (deep unions, wide CTEs) a Python loop that submits thousands of small jobs EXECUTOR PROBLEM "The distributed work is too heavy." Symptoms scale with DATA. Fixes: partitioning, skew, joins, file layout, memory per task. Adding executors can help. DRIVER PROBLEM "The coordinator is being asked to do too much." Symptoms scale with TASK COUNT, FILE COUNT and RESULT SIZE. Adding executors makes it WORSE — more tasks to track.
Diagram 18 · Driver vs executor. Two problems that look identical from the outside and have opposite fixes.

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.

📊 Verify — is the driver actually the bottleneck?

Three signals, all cheap:

  1. Executors idle while the job is "running." Executors tab shows active tasks well below total slots and nothing pending-blocked.
  2. Driver CPU pinned. If you have host metrics, look at the driver process specifically.
  3. 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.

🎯 Interview insight

"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."
§ Part XI · CPU

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.

▸ THE PER-ROW COST HIERARCHY a tendency, not a law — the exceptions at the bottom of this diagram matter 1 · NATIVE CATALYST EXPRESSION built-in SQL functions · visible to the optimizer · compiled into the generated stage code optimizer can push down, reorder and eliminate it 2 · VECTORISED / COLUMNAR columnar Parquet/ORC readers · batch-at-a-time processing · cache-friendly amortises per-row overhead across a whole batch 3 · ARROW / PANDAS UDF BOUNDARY data crosses to Python in Arrow batches · one serialization per batch, not per row still opaque to the optimizer, but the transfer is cheap 4 · ROW-AT-A-TIME PYTHON UDF every row serialized to a Python process and back · plan shows BatchEvalPython per-row serialization + an optimizer barrier Exceptions are real: a well-written Python UDF over a small filtered set can beat a monstrous native expression tree; some algorithms have no native equivalent; and an ill-conceived native regex over a billion rows is worse than any of these. Measure the stage, don't apply the ladder blindly.
Diagram 19 · Per-row cost hierarchy. Two axes at once: transfer cost, and how much the optimizer can still do for you.

Where the CPU actually goes

SourceWhy it costsWhat to try first
Row-at-a-time Python UDFSerialize each row out of the JVM, run Python, serialize back. Also a barrier: the optimizer cannot push filters through itReplace 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 UDFNo serialization cost, but still opaque to the optimizer — no pushdown, no constant folding, no null-handling shortcutsPrefer built-ins. A UDF is fine when the logic genuinely has no SQL equivalent
pandas / Arrow UDFFar cheaper transfer, but batch size drives memory: a large batch × many concurrent workers is a container-kill riskTune the batch size deliberately; watch Python worker memory (Part V)
RegexBacktracking patterns are superlinear. A bad pattern over a billion rows is an outageAnchor patterns; prefer like/startswith/contains when they suffice; pre-filter before matching
JSON parsingParsing a string column per row is expensive and often repeated for each field extractedParse once into a struct with an explicit schema, then select fields — never call a JSON extract N times on the same column
Compression / decompressionHeavier codecs trade CPU for I/O. On a CPU-bound stage that trade is backwardsMatch the codec to the bottleneck: cheaper codec when CPU-bound, denser when I/O- or network-bound
EncryptionClient-side or in-transit encryption is pure CPU on top of everything elseUsually non-negotiable — but account for it when sizing, and don't diagnose it as "Spark is slow"
Expensive window functionsEach distinct window specification implies a shuffle and a sort; unbounded frames can be O(n²)-ish per partitionReuse one window spec across several expressions; bound frames; check whether an aggregate + join is cheaper
Repeated expressionsThe same subexpression computed several times per rowCompute once in a subquery or CTE and reference it. Verify in the plan that it was not re-inlined
Object serializationEncoding and decoding between internal formats and JVM objectsStay in the DataFrame/SQL API; typed Dataset lambdas force object materialisation
🧠 Whole-stage code generation, and how you break it

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.

🔎 How to prove a stage is CPU-bound
  1. Task duration is uniform (max ≈ median) — so it is not skew.
  2. Spill is zero — so it is not memory.
  3. Shuffle read blocked time is near zero — so it is not the network.
  4. Input bytes per task are modest, but duration is long — so it is not I/O volume.
  5. 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.

🎯 Interview insight

"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 CASE was 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 whether PushedFilters changed, 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."
✦ ✦ ✦
§ Part XII · storage & files

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.

▸ SAME 150 GB. NOT THE SAME JOB. LAYOUT A — 50,000 × 3 MB … × 50,000 ▸ ~50,000 object-store list + open operations, most of them latency, not bandwidth ▸ every open pays a fixed cost regardless of how few bytes it returns ▸ Parquet footer read per file — metadata cost scales with FILE COUNT, not data size ▸ row groups too small to compress or encode well → more bytes for the same data ▸ min/max statistics per file are nearly useless — each covers too little to skip anything ▸ driver spends minutes listing before a single task runs ▸ task count driven by file count → thousands of tasks each doing milliseconds of work The bytes are the same. The OVERHEAD is 50,000 × a fixed cost. On object storage, that fixed cost is a network round trip. On HDFS it is a NameNode call. Either way it is latency you cannot parallelise away, because the driver does the listing. LAYOUT B — ~300 × 512 MB … × 300 ▸ ~300 list + open operations — three orders of magnitude fewer round trips ▸ large row groups compress and encode well → fewer bytes on the wire ▸ per-file and per-row-group statistics are meaningful → real data skipping ▸ Spark splits large files into partitions by BYTES, so parallelism is preserved ▸ listing finishes in seconds; tasks start immediately Big files do not reduce parallelism — maxPartitionBytes still splits them. But files can also be TOO big: a single 40 GB file that is not splittable (some compression codecs are not) becomes exactly one task. Check splittability. The target is a size band, not a number: large enough that per-file overhead disappears, small enough that a file is not a unit of failure or a scheduling bottleneck.
Diagram 20 · 50,000 × 3 MB vs 300 × 512 MB. Identical bytes, incomparable jobs.

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:

OptimizationWhat it skipsWhat enables itHow to verify
Partition pruningEntire directoriesThe table is physically partitioned by the filtered column, and the filter is on the bare columnPartitionFilters populated in the scan node; "number of partitions read" metric
Predicate pushdown / data skippingRow groups within a fileThe predicate can be evaluated against row-group statisticsPushedFilters populated; bytes read far below file size
Column pruning (projection)Columns you never referenceYou did not write SELECT *ReadSchema in the scan node lists only the columns you need
🚨 The number one way to lose partition pruning

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

SettingApache defaultWhat it doesWhen to move it
spark.sql.files.maxPartitionBytes128 MBTarget bytes per read partition when splitting filesLower 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.openCostInBytes4 MBThe estimated cost of opening a file, expressed in bytes, used when packing small files into partitionsRaising 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 / .parallelism32 / 10000When and how widely to distribute partition-directory listing instead of doing it on the driverTables with very many partitions where the driver spends minutes listing
spark.sql.files.minPartitionNum / .maxPartitionNumunsetFloor/ceiling on the number of read partitions availability varies by versionWhen byte-based splitting alone gives you too few or absurdly many tasks
🧠 Object storage is not a filesystem, and this changes your instincts

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.

🛠 Fixing a small-file problem at the source

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 REBALANCE Spark 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-coded repartition(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.
🎯 Interview insight

"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 openCostInBytes as an interim measure so Spark packs more small files into each task."
§ Part XIII · partitioning

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.

▸ THREE THINGS CALLED "PARTITIONING" 1 · STORAGE PARTITIONING physical layout on disk /sales/ds=2026-08-24/… /sales/ds=2026-08-25/… /sales/ds=2026-08-26/… ← only this is read Controls: WHAT YOU READ Set by: PARTITIONED BY at write time Changed by: rewriting the table Too few → no pruning, full scans Too many → millions of directories, slow listing High-cardinality key (user_id) → catastrophic Fix: choose the column your queries filter on. 2 · SPARK EXECUTION PARTITIONS units of parallelism when reading one file of 2 GB, maxPartitionBytes 128 MB → ~16 read partitions → 16 tasks many tiny files → packed by openCostInBytes Controls: HOW MANY TASKS READ IT Set by: file sizes + maxPartitionBytes Changed by: repartition / coalesce mid-job Too few → idle slots, huge tasks, spill Too many → scheduling overhead dominates Not settable directly — it is derived Fix: change file layout or maxPartitionBytes. 3 · SHUFFLE PARTITIONS units produced by an Exchange spark.sql.shuffle.partitions = initial count AQE then coalesces toward advisoryPartitionSizeInBytes Controls: PARALLELISM AFTER A SHUFFLE Set by: the config, then adjusted by AQE Changed by: config, hints, REBALANCE Too few → spill, huge blocks, fetch timeouts Too many → tiny tasks, tiny output files This is what people MEAN by "partitions" Fix: set generously; tune the advisory SIZE. Diagnostic value: "increase partitions" is meaningless until you say WHICH. Changing shuffle partitions does nothing for a full-table scan caused by a broken partition filter.
Diagram 21 · Storage vs execution vs shuffle partitions. Same word, three layers, three toolkits.

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_id in 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

OperationShuffles?What it gives youThe trap
repartition(n)Yes, fullExactly n partitions, round-robin distributed — evenly sizedCosts a full shuffle. Doing it "to be safe" before a write is a common waste
repartition(n, col)Yes, fulln partitions hash-partitioned by col — co-locates equal keysDoes 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 pruningUses sampling to pick boundaries, so partition sizes are approximate and slightly non-deterministic
coalesce(n)NoMerges partitions without moving data across the network — cheapPropagates upstream. See below. Also produces unevenly sized partitions
REBALANCE hintYesLets AQE pick boundaries to hit an even target size, splitting skewed partitionsSpark 3.3+; needs AQE enabled
🚨 The coalesce trap, precisely stated

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.

🎯 Interview insight

"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
"coalesce reduces parallelism for the whole stage, not just the write, so the computation is now running with 10 tasks."
Senior
"coalesce is 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, a REBALANCE hint 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."
✦ ✦ ✦
§ Part XIV · storage-partition-aware joins

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.

▸ THE SAME JOIN, TWO PHYSICAL PLANS WITHOUT storage-aware partitioning Scan orders Scan payments Exchange (shuffle) Exchange (shuffle) Sort Sort SortMergeJoin 2 full shuffles + 2 sorts before a single row is joined WITH compatible partitioning Scan orders Scan payments Join no Exchange. no Sort. no network transfer for the join. the source REPORTED its partitioning, and Spark believed it because the layouts on both sides are compatible The win is not a percentage — it is the removal of an entire class of cost. Shuffle bytes go to zero for this join, and with them the spill, the fetch failures and the retry risk.
Diagram 22 · Physical plan before and after storage-aware partitioning. Four nodes deleted, not optimised.

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.
📊 How to tell whether it engaged

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.

🧠 The older cousin: bucketing

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.

✦ ✦ ✦
§ Part XV · caching

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.

▸ SHOULD I CACHE THIS? Q1 · Is it read more than once? NO DON'T CACHE you pay storage cost for zero reuse YES Q2 · Is recomputing it expensive? PROBABLY DON'T a cheap scan re-read beats cache pressure Q3 · Does it FIT? PARTIAL CACHE = WORST CASE evicted blocks are recomputed anyway, and you paid GC + memory for the rest Q4 ↓ CACHE — and unpersist when done project columns FIRST · choose the storage level · measure Q4 · Would that memory serve EXECUTION better? Storage and execution share the same pool. Every gigabyte you pin as cache is a gigabyte joins and aggregations cannot use. If the job already spills, caching makes it spill MORE. Check the Storage tab: fraction cached, and size in memory. "Fraction cached < 100%" means you are already losing.
Diagram 23 · The caching decision tree. Four questions. Most caches in production fail at question one.

The mechanics

APIWhat it doesNote
df.cache()Marks the DataFrame for caching at the default storage levelLazy — 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 blocksThe step everyone forgets. In a long session, forgotten caches accumulate silently
CACHE TABLE t / UNCACHE TABLE tThe SQL equivalentCACHE TABLE is eager by default, unlike df.cache() — a useful difference to know
🛠 When caching genuinely earns its keep
  • 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.

⚠️ Where it backfires
  • 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.
🎯 Interview insight

"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."
§ Part XVI · sizing

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.

▸ FOUR WAYS TO SIZE IT WRONG TOO FEW EXECUTORS long queue of pending tasks symptom: tasks pending, all slots busy TOO MANY EXECUTORS grey = paid for, did nothing TOO MANY CORES / EXECUTOR 16 cores, 32 GB heap → 16 tasks share one heap → ~2 GB of unified memory each → spill, GC contention, 16 Python workers per container concurrency up, memory per task down TOO FEW CORES / EXECUTOR 1 core, 8 GB heap, × 200 → 200 JVMs to start and manage → broadcast copied 200 times → no in-executor task sharing → more shuffle connections overhead per unit of work rises ▸ DYNAMIC ALLOCATION TIMELINE — what the knobs actually control t0 ramp steady tail idle release initialExecutors maxExecutors minExecutors schedulerBacklogTimeout: how long a backlog must persist before asking for more executorIdleTimeout: how long an idle executor waits before being released Holding shuffle output complicates release: an executor with shuffle data another stage still needs cannot simply go away. That is what shuffle tracking (or an external shuffle service) exists to solve.
Diagram 24 · Sizing failure modes and the dynamic-allocation timeline. Width is not speed.

The knobs, and what evidence should move each

SettingApache defaultMove it when the evidence says…
spark.dynamicAllocation.enabledfalse often true on managed platformsYour workload has variable parallelism across stages and you can tolerate ramp-up latency
spark.dynamicAllocation.minExecutors / maxExecutors / initialExecutors0 / infinity / = minRaise 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.schedulerBacklogTimeout1sRarely. Shortening it makes ramp-up more aggressive and more wasteful on bursty jobs
spark.dynamicAllocation.executorIdleTimeout60sLower it when you see long idle tails costing money; raise it when executors are being released and immediately re-requested
spark.dynamicAllocation.shuffleTracking.enabledfalseYou want dynamic allocation without an external shuffle service — Spark then keeps executors that still hold needed shuffle data
spark.executor.cores1 (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.instances2 (when dynamic allocation is off)Only after you know tasks are actually pending. Pending tasks justify more executors; idle slots never do
🧠 Cores per executor is a memory decision disguised as a CPU decision

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.

💰 Latency versus cost, stated plainly

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.

🎯 Interview insight

"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."
§ Part XVII · stragglers

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.

▸ SAME SYMPTOM. OPPOSITE CAUSE. DATA SKEW 100 normal tasks~30 s each340 MB each task 10112 min41 GB Duration ratio: 24× · Input ratio: 120× Duration scales WITH the data. The task is slow because it was given more work. Speculation will NOT help — a duplicate copy of the same enormous partition takes exactly as long on a fresh host. Fix the distribution. Part VII. STRAGGLER 100 normal tasks~30 s each340 MB each task 10112 min338 MB Duration ratio: 24× · Input ratio: 1.0× Duration does NOT scale with data. The task is slow because of WHERE it ran. Candidates: degraded disk, noisy co-tenant, throttled instance, failing NIC, a host mid-GC-death, or an executor that is being decommissioned. Speculation DOES help here. ▸ THE DISCRIMINATOR IS ONE COLUMN: compare the slow task's INPUT or SHUFFLE READ against the median. Everything else is inference.
Diagram 25 · Skew vs straggler. The task table looks the same. The input column does not.

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.

SettingApache defaultMeaning
spark.speculationfalse frequently enabled by managed platformsMaster switch
spark.speculation.interval100msHow often Spark checks for tasks to speculate
spark.speculation.multiplier1.5A task is speculatable if it exceeds this multiple of the comparison duration
spark.speculation.quantile0.75Fraction of tasks in the stage that must complete before speculation may start
⚠️ Speculation is not a skew treatment, and it is not free

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.

🔎 Finding a bad node in thirty seconds

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.

🎯 Interview insight

"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."
§ Part XVIII · network

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

SymptomWhat it usually isWhat it occasionally is
FetchFailedExceptionThe executor holding the shuffle blocks diedGenuine packet loss; an overloaded external shuffle service; blocks too large to transfer in time
Executor disappearanceMemory kill, preemption or node lossNetwork partition — the executor is alive but unreachable
Connection resetThe other end went away mid-transferA middlebox, security group change, or connection-limit exhaustion
RPC / network timeoutThe peer was too busy to answer (usually GC)Real latency on a stretched or cross-zone network
Heartbeat timeoutLong GC pause or CPU starvation on the executorDriver too busy to process heartbeats — a driver-side problem wearing an executor-side symptom
Large remote blocksSkew — one partition's block is enormousAn intentionally huge advisory partition size
Retry stormA cascade: one death causes fetch failures, which cause stage re-runs, which cause more pressureRarely anything else. Once you see repeated stage attempts, stop tuning and find the first death
Decommissioning interactionsSpot/preemptible reclamation removing executors that still hold shuffle outputAutoscaling releasing executors too eagerly without shuffle tracking
🧠 Why a fetch failure is so much more expensive than it looks

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

SettingApache defaultWhat it really controlsLegitimate reason to change it
spark.network.timeout120sDefault timeout for most network interactions, including heartbeat detectionA genuinely high-latency environment, or a known long pause you are actively working to remove
spark.executor.heartbeatInterval10sHow often executors report liveness and metricsVery rarely. Must stay significantly below the network timeout
spark.shuffle.io.maxRetries3Retries for a failed block fetchTransient, genuinely recoverable network conditions
spark.shuffle.io.retryWait5sWait between fetch retriesAs above — and remember retries × wait is added latency on every failure
spark.reducer.maxSizeInFlight48mHow much shuffle data a reducer requests concurrentlyLower it to relieve memory pressure on reducers; raise it on fast networks with spare memory
spark.task.maxFailures4Task attempts before the stage is failedAlmost never. Raising it hides a recurring failure and pays for it four more times
spark.stage.maxConsecutiveAttempts4Stage retries after fetch failures before giving upAlmost never — this is the retry-storm ceiling, and raising it makes storms longer
🛠 The correct order of investigation for any fetch/network failure
  1. Did an executor die? Executors tab, filter by dead, match timestamps. This resolves the majority of cases immediately.
  2. Why did it die? Its own log, its exit code, the cluster-manager events. Memory kill, preemption, disk, node loss.
  3. Are shuffle blocks pathologically large? Max shuffle read vs median. If yes, this is skew wearing a network costume.
  4. Is one host implicated repeatedly? If so, the fix is to stop scheduling on it, not to tune Spark.
  5. Are host network metrics actually saturated? Only now is "the network" a finding rather than a guess.
  6. 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.
🎯 Interview insight

"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."
§ Part XIX · local disk

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.

▸ THE SPILL FEEDBACK LOOP Memory pressure Spill to local disk Local disk I/O saturates Tasks run slower Executors live longer More shuffle files accumulate and if the disk fills: executor dies → its shuffle output is lost → map stages re-run → more spill → faster loop Break the loop at the TOP. Every downstream fix — bigger disks, more retries — buys time; only reducing memory pressure ends it.
Diagram 26 · The spill feedback loop. A self-reinforcing cycle that starts as a memory problem and ends as a network incident.

Symptoms, in the order they usually appear

  1. Spill (disk) appears in stage metrics where it used to be zero.
  2. Task durations creep up as the stage progresses — later tasks contend with earlier tasks' spill files.
  3. Shuffle write time rises even though shuffle bytes are unchanged.
  4. Executors are lost with "no space left on device", or die silently after the volume fills.
  5. Fetch failures and stage retries begin — the loop above is now running.
🔎 Where to look when you suspect disk
  • Stage metrics: Spill (memory) and Spill (disk). Both non-zero and large is the signature.
  • Executor logs: search for No space left on device, IOException on 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.
🛠 Fixes, best first
  1. 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.
  2. Remove unnecessary shuffles — a broadcast join that should have happened, a redundant repartition, a sort you did not need.
  3. 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.
  4. Reduce shuffle volume by projecting fewer columns and pre-aggregating before the exchange.
  5. 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.
✦ ✦ ✦
§ Part XX · SQL

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 *

✗ Bad
SELECT *
FROM   events e
JOIN   users u ON e.user_id = u.user_id
WHERE  e.ds = '2026-08-26';
✓ Better
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

✗ Bad
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 join
✓ Better
SELECT 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 needed

Why. 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

✗ Bad
SELECT * FROM transactions
WHERE ds = '2026-08-26'
ORDER BY event_ts;          -- 4 billion rows, globally ordered
✓ Better
-- 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 partitions

Why. 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

✗ Bad
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;
✓ Better
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 aggregates

Why. 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

✗ Bad
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 product
✓ Better
SELECT a.*, b.rate
FROM   orders a
JOIN   fx_rates b
  ON   a.currency = b.currency
 AND   a.rate_date = b.rate_date;   -- explicit, complete, reviewable

Why. 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

✗ Bad
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 high
✓ Better
SELECT 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, guaranteed

Why. 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

✗ Bad
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 column
✓ Better
WHERE 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 type

Why. 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

✗ Bad
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';
✓ Better
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()

✗ Bad
SELECT user_id, explode(page_views) AS pv
FROM   sessions;               -- one session can carry 400,000 page views
✓ Better
-- 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

✗ Bad
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 row
✓ Better
WITH 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 schema

Why. 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

✗ Bad
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 sorts
✓ Better
SELECT 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 shuffle

Why. 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

✗ Bad
@udf("string")
def clean_email(s):
    return s.strip().lower() if s else None

df.withColumn("email", clean_email("email_raw"))
✓ Better
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 it

Why. 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.

🎯 Interview insight

"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 populated PartitionFilters, 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."
✦ ✦ ✦
§ Part XXI · data problems in disguise

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 FIRST DASHBOARD TO BUILD: VOLUME, NOT TIMING SIGNAL YESTERDAY TODAY Δ WHAT IT MEANS IF IT MOVED ALONE Input bytes Input rows Output rows Output ÷ input rows Avg record width Input file count 2.1 TB2.2 TB+5%normal growth — not your problem 4.10 B4.28 B+4%consistent with bytes — fine 4.10 B 20.9 B+410%FAN-OUT. a join multiplied rows. Part IX. 1.00 4.88×4.9the single most diagnostic ratio on this page 512 B505 B−1%stable — so it is not record bloat 3,1003,240+5%stable — so it is not a small-file event Diagnosis in under a minute: input is flat, output is 5×, therefore the change is INSIDE the query's cardinality, not in the volume or the cluster. Without this table, the same investigation is an afternoon of Spark UI archaeology. With it, you are already talking to the owner of the dimension table.
Diagram 27 · Input/output volume dashboard. Six numbers that pre-empt most "Spark is slow" investigations.

The ten data events that page you as a Spark incident

EventSpark-side symptomThe one-line check
Duplicate dimension rowsOutput rows a clean multiple; shuffle up; downstream sums wrongGROUP BY key HAVING COUNT(*) > 1
Unexpected NULL explosionOne partition enormous — all NULLs hash togetherCOUNT(*) FILTER (WHERE key IS NULL) as a share of total
Schema driftAnalysisException, or silently wrong results when a type widenedCompare the source schema against the last known good
Record-size explosionBytes up sharply while rows are flat; spill appearsAverage bytes per row, tracked over time
Bad delimiter / parser behaviourRow counts wildly off; one column contains the whole lineRow count vs expected; distinct count of a column that should be low-cardinality
Nested JSON growthCPU up, bytes up, rows flat — parsing more per recordAverage payload length percentiles
Suddenly larger storage partitionsA few read tasks are enormous; skew before any shuffle existsBytes per partition directory, top 20
Corrupted filesParquetDecodingException or a task that fails only on retry-specific filesIdentify the file from the trace; check the writer's history
Source reprocessingInput doubles overnight with no code changeInput bytes vs the same weekday last week
Upstream duplicate loadInput exactly 2×; every downstream metric doublesDistinct count of a natural key vs row count
🛠 The cheapest instrumentation in this handbook

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.

🎯 Interview insight

"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."
§ Part XXII · regressions

Reading performance regressions.

Here is a real-shaped regression. Before you read on, decide what you think happened.

MetricYesterdayTodayChange
Input2.1 TB2.2 TB+5%
Runtime42 min126 min×3.0
Shuffle3.4 TB14.8 TB×4.4
Executor-hours160520×3.3
🧠 What the numbers already tell you

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.

MetricWhy it is on the cardHealthy patternAlert condition (ratio, not absolute)
RuntimeThe SLA numberStable ±15%> 1.5× rolling median for the same weekday
Input bytesSeparates data events from code eventsTracks business growth> 1.3× or < 0.7× of the same weekday last week
Input rowsDistinguishes wider records from more recordsTracks bytesBytes/rows ratio moves > 20%
Output rowsCorrectness canaryTracks inputOutput ÷ input deviates from its own baseline
Output bytes / file countSmall-file and layout regressionsStable bytes per fileFile count > 2× with flat bytes
Shuffle read / writeThe plan's fingerprint in bytesRoughly proportional to inputShuffle ÷ input moves > 50%
Spill (memory + disk)The honest memory signalZero, or stable and smallAny appearance where there was none
GC time ÷ task timeExecutor healthLow single digitsDoubling, or crossing your own established band
CPU time ÷ wall timeAre we computing or waiting?StableFalls sharply → new I/O or lock wait
Task countPartitioning and file-layout changesStable per unit of input> 2× with flat input
Task p50 / p95 / maxSkew detection, automatedmax ÷ p50 stable and modestmax ÷ p50 exceeds its own baseline
Executor-hoursThe cost numberTracks runtime × sizeRises while runtime falls or stays flat
Failed / retried tasksHidden instabilityZeroAny non-zero value, trended
Executor lossesThe precursor to retry stormsZeroAny, especially clustered in time
Cost per run / per TBThe number finance seesFalling per TB over timeCost per TB rising
SQL plan fingerprintCatches plan flips with no code changeIdentical between runsChanged — investigate before the runtime does it for you
📊 Plan fingerprinting, cheaply

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.

§ Part XXIII · cost

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.

VersionRuntimeExecutorsExecutor-hoursRelative costMeets a 06:00 SLA?
A90 min1001501.0×Yes
B50 min5004172.8×Yes
C58 min1801741.16×Yes
▸ RUNTIME vs COST — the curve nobody plots RUNTIME (minutes) → EXECUTOR-HOURS → 45 60 75 90 450 300 150 A — 90 min · 150 eh cheapest. meets SLA. boring. correct. B — 50 min · 417 eh 2.8× the cost to finish 40 min earlier that 40 min is worth nothing against an 06:00 SLA C — 58 min · 174 eh 92% of B's speed at 42% of B's cost the wall: past here you buy nothing but bill The curve flattens because the serial and skewed portions of the job do not scale. Find your own elbow — it is workload-specific, and it moves when the data changes.
Diagram 28 · The cost/runtime trade-off. B is the fastest. C is the right answer.

Where the money actually goes

Cost sourceHow it hidesHow to find it
Idle executorsHeld through a long tail while two skewed tasks finishSlot utilisation over time; the gap between allocated and active executors
Oversized instancesA memory-shaped instance for a CPU-bound job, or vice versaCompare peak memory used against provisioned; compare CPU utilisation
Cluster startupInvisible in the Spark UI entirelyOrchestrator timestamps (Part III). On short jobs this can be a third of the bill
Excessive retriesEvery re-executed stage is paid for twiceStage attempt counts; failed task counts
Re-reading dataThe same source scanned in three subqueriesCount of scan nodes; total input bytes vs table size
ShuffleThe most expensive operation, priced as "compute"Shuffle bytes as a ratio to input bytes
Storage operationsListing and per-request charges on small-file layoutsFile counts; your storage provider's request metrics Platform-dependent
Over-provisioned SLAA job that must finish by 06:00 tuned to finish at 03:00Ask the consumer when they actually need it. Frequently the cheapest optimization available
💰 Two metrics that change conversations

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.

🎯 Interview insight

"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."
§ Part XXIV · method

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.

▸ THE TUNING LOOP 1 · BASELINE record everything 2 · BOTTLENECK which stage, which level 3 · HYPOTHESIS falsifiable sentence 4 · ONE VARIABLE exactly one 5 · RUN comparable workload 6 · MEASURE the same metrics 7 · COMPARE vs baseline 8 · KEEP or revert reverted? new hypothesis — with one more measurement than you had before 9 · DOCUMENT why, not just what 10 · REGRESSION TEST so it cannot come back ▸ WHY "ONE VARIABLE" IS NOT PEDANTRY Change shuffle partitions AND executor memory AND cores together, and a 30% improvement is unattributable. You cannot tell which change to keep, which to revert when the data grows, or what to tell the next engineer. Worse: two of the three may have HURT, and the third carried them. Spark runs also have real variance — cluster state, spot mix, upstream data, cache warmth. If the effect is small, run the comparison more than once before believing it. A 6% "improvement" from a single pair of runs is usually noise wearing a lab coat.
Diagram 29 · The scientific tuning loop. Ten steps. Step 4 is the one people skip and the one that makes the rest work.

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

MetricBaselineAfterPredicted?
Median task shuffle read7.8 GB1.9 GB
Spill (disk)9 TB0 B✓ — the confirming metric
Stage 12 duration61 min17 min
Job runtime84 min39 min
Executor-hours224104bonus: cheaper as well as faster
Task count (stage 12)8003,180expected side effect
Output file count8003,180watch 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.

🎯 Interview insight

"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."
✦ ✦ ✦
§ Part XXV · the matrix

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.

SymptomSpark UI evidenceLogsLikely causeFirst investigationPossible fixDangerous knee-jerk
Stuck at 99%1–3 tasks running, thousands complete; max ≫ median durationUsually silentSkew, or a straggler hostCompare slow task's shuffle read to medianIsolate/filter hot key; salt; AQE skew thresholdsAdd executors — the idle 997 slots are already free
All tasks slowmax ≈ median, every task large; spill presentSpill messagesUnder-partitioning, or expensive per-row workPer-task input bytes; CPU time ÷ durationRaise partition count; lower advisory size; remove UDFIncrease executor memory to absorb bigger partitions
High disk spillSpill (disk) in GB/TB per taskNo space left in bad casesPartition larger than per-task execution memoryHeap ÷ cores × memory.fraction vs partition sizeMore partitions, or fewer cores per executorBigger local disks — treats the symptom, feeds the loop
High memory spillSpill (memory) non-zero, disk spill lowQuietExecution memory tight but recoverablePeak execution memory vs budgetReduce cores per executor; smaller partitionsIgnore it — it is the early warning for the row above
Executor heap OOMFailed tasks; stage retriesOutOfMemoryError: Java heap spaceOne task's working set exceeds heap shareSkew check first, alwaysFix skew, or raise parallelism, or cut coresDouble executor memory before checking distribution
Driver OOMWhole application diesOOM with collect/Broadcast/plan framescollect, toPandas, huge broadcast, millions of tasksRead the stack trace's top framesRemove the collect; cap broadcast; cut task countRaise driver memory and maxResultSize
Memory overhead exceededExecutors vanish; no Spark exceptionContainer killed / OOMKilled / exit 137Non-heap: Python workers, native, off-heapIs this PySpark? how many cores per executor?Cap pyspark memory; fewer cores; raise overhead + containerRaise executor.memory inside the same container
GC overheadGC Time a large fraction of Task TimeGC overhead limit exceededLarge live set: caching, churn, fat heapsStorage tab; is GC growing over the stage?Unpersist; project before caching; fewer coresChange the GC algorithm first
FetchFailedRepeated stage attempts; dead executorsFetchFailedExceptionThe block's executor died — cause is elsewhereEarliest executor death and its own logFix that death; shrink oversized blocksRaise fetch retries and wait
Heartbeat timeoutExecutor removed mid-stageno recent heartbeatsLong GC pause, or CPU starvationThat executor's GC ratio; host CPUReduce GC pressure; fix oversubscriptionRaise spark.network.timeout
Executor lostExecutors tab shows removalsExecutorLostFailure, exit codeMemory kill, preemption, node loss, diskExit code; cluster-manager eventsDepends on classification — do not skip itRaise spark.task.maxFailures
Stage retry stormSame stage at attempt 3, 4…Cascading fetch failuresOne death causing map-output loss, repeatedlyFind the FIRST failure; ignore the restFix root death; consider external shuffle serviceRaise stage.maxConsecutiveAttempts
Output row explosionOutput rows ≫ input rowsSilentJoin fan-out or explodeDuplicate-key test on every dimensionDedup to the right grain; filter SCD to currentAdd DISTINCT and move on — hides a correctness bug
Unexpectedly huge scanInput bytes = table sizeSilentPartition pruning lostPartitionFilters in the scan nodeBare, correctly-typed partition predicateAdd executors to scan faster
Tiny filesHuge task count; tiny per-task input; long pre-stage gapSlow listingUpstream writer producing many small filesFile count and bytes-per-file for the sourceCompact; fix the writer; raise openCostInBytesIncrease maxPartitionBytes only — listing still dominates
Millions of tasksTask count in the millions; driver CPU pinnedDriver GCPartition sizing or file-count driven explosionWhere does the count come from — files or shuffle?Coalesce inputs; AQE coalescing; bigger advisory sizeRaise driver memory and continue
Low CPU, job slowCPU time ≪ duration; high blocked timeObject-store retriesI/O or network boundBlocked time; storage request metricsBetter file layout; fewer round trips; more read parallelismAdd executors — more clients on the same throttled path
High CPU, job slowCPU time ≈ duration; uniform tasks; no spillQuietExpensive per-row workPlan for UDF / regex / JSON nodesNative expressions; Arrow UDFs; filter earlierAdd memory — it is not a memory problem
Cluster underutilisedActive tasks ≪ total slotsQuietNot enough partitions, or a driver bottleneckTask count vs slot countMore partitions; fix driver-side workScale up — worsens a driver bottleneck
Tasks pending, slots idlePending tasks with free slotsLocality waitsPlacement/locality, or a resource-profile mismatchLocality levels; cluster-manager allocation stateRelax locality expectations; check quotasRequest more executors that also cannot be scheduled
Broadcast timeoutJob fails during a broadcast exchangebroadcastTimeout exceededBuild side too large or too slow to collectBuild-side size metric in the SQL tabStop broadcasting it; fix statisticsRaise the timeout and broadcast it anyway
Broadcast OOMDriver dies during exchangeOOM with broadcast framesA hinted or mis-estimated build sideActual size vs thresholdRemove the hint; refresh statistics; use sort-mergeRaise autoBroadcastJoinThreshold globally
Python worker failureTask failures in Python stagesTraceback, segfault, or nothingMemory kill, native crash, or a bad recordPresence of a traceback vs container eventCap Python memory; fix the record; fewer coresRetry the job and hope
collect/toPandas crashJob dies at an actionmaxResultSize or driver OOMReturning a distributed dataset to one machineThe action in the stack traceAggregate first; write to storage; sampleSet maxResultSize=0
Stale statisticsPlan chooses a strategy that contradicts real sizesQuietStatistics missing or outdatedEXPLAIN COST vs actual size metricsANALYZE TABLE … COMPUTE STATISTICSPin the strategy with a hint and never revisit
Slow object-store listingMinutes before the first task; no active stageListing messagesToo many files or partition directoriesObject/prefix counts for the pathCompact; reduce partition cardinality; parallel discoveryIncrease executor count — the driver is doing the listing
Bad node / stragglerSlow tasks concentrated on one executor idHost-level errorsDegraded hardware or a noisy neighbourGroup slow tasks by executor and hostEnable speculation; drain the hostSalt the join key — the data was never skewed
Excessive shuffleShuffle bytes ≫ input bytesQuietPlan moving more than necessaryDiff the plan against a known-good runBroadcast; pre-aggregate; project earlier; compatible layoutIncrease network timeouts to survive it
Scheduler overheadHundreds of tiny jobs; driver busy; executors idleQuietA loop in the driver submitting actionsJob count in the Jobs tabExpress the loop as data; one jobParallelise the loop with threads on the driver
Repeated file readsSeveral scan nodes on the same sourceQuietSubqueries or an un-reused CTECount scan nodes; look for ReusedExchangeConditional aggregation; materialise deliberatelyCache everything
Cache pressureStorage tab shows partial caching; GC up; spill appearsBlock eviction messagesCached data competing with executionFraction cached; spill before vs afterUnpersist; project first; pick a disk-inclusive levelRaise storageFraction and starve execution further
Job slower after adding coresMore concurrency, new spill, more GCContainer kills in PySparkPer-task memory fell as cores roseHeap ÷ cores; Python worker countRevert cores; raise partitions insteadAdd even more cores
Long tail, cluster idleAllocated executors ≫ active during the tailQuietSkew or an unbalanced final stageSlot utilisation over timeFix the tail's distribution; lower idle timeoutNothing — and keep paying for the idle tail
Fails only in productionWorks on a sample, dies at full scaleAny of the aboveA threshold crossed: broadcast size, memory, cardinalityCompare the two plans and the two input volumesFix the mechanism the scale exposedGive production a bigger cluster and forget it
✦ ✦ ✦
§ Part XXVI · case files

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.

CASE 01

Stuck at 99% for two hours

skewsentinel key
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_id carries the sentinel -1 for 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.
CASE 02

Every reducer slow, nothing skewed

under-partitioningspill
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.partitions was 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 REBALANCE hint 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.
CASE 03

The driver dies at the last step

drivercollect
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 shows collect called 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() and toPandas() outside explicitly reviewed code paths. maxResultSize left at its default deliberately — it is the guard that caught this.
CASE 04

Containers killed with a half-empty heap

PySparkoverhead
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 OutOfMemoryError anywhere. 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.memory set 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."
CASE 05

The four-hour fetch-failure storm

cascadeshuffle
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.maxRetries and spark.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 device while 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.
CASE 06

Job slower after we gave it more cores

sizingmemory per task
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.
CASE 07

Fifty thousand tiny Parquet files

file layoutlisting
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 REBALANCE before the upstream write to target sensible file sizes. openCostInBytes raised 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.
CASE 08

The partition filter that stopped filtering

pruningone character
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. PushedFilters shows 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' to WHERE 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 EXPLAIN on each pipeline query and fails if a partitioned source has an empty PartitionFilters. Input bytes added to the scorecard with a ratio alert.
CASE 09

Forty-times output from a dimension nobody touched

fan-outcorrectness
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.
CASE 10

The broadcast table that grew up

broadcasthint rot
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 BroadcastExchange node reports a build side of 3.8 GB. Driver memory: 4 GB.
Log evidence
Driver: OutOfMemoryError: Java heap space with broadcast frames in the stack. Earlier runs show broadcastTimeout warnings — 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.
CASE 11

A Python UDF eats the cluster

CPUUDF
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 BatchEvalPython node between two previously-fused regions. CPU time ÷ task duration is near 1.0 — pure compute. And critically: PushedFilters on 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 + a CASE). 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.
CASE 12

One session, four hundred thousand page views

explodegenerated skew
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 Generate node'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.
CASE 13

Forty-five percent of the cluster collecting garbage

GCcache
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 matching unpersist().
CASE 14

Five hundred executors, thirty doing work

driverscheduler overhead
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 for loop 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.
CASE 15

Listing takes longer than computing

object storepartition cardinality
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 ds only, with clustering/sorting on customer_id inside 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.
CASE 16

Stale statistics choose the wrong join

optimizerstatistics
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: BroadcastHashJoin became SortMergeJoin. 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.
CASE 17

The caching "optimization" that cost 30%

cachememory contention
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.
CASE 18

A raised timeout hides an unhealthy host

stragglermasked symptom
Incident
Intermittent executor losses were "fixed" three months ago by raising spark.network.timeout to 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.
CASE 19

The source doubled and nobody said anything

upstreamnot a Spark bug
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.
CASE 20

40% faster, 3× the bill

costunmeasured win
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 maxExecutors from 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.
✦ ✦ ✦
§ Part XXVII · interview mode

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.

🎯 Scenario 1 · the opening question

"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."
🎯 Scenario 2

"What is the difference between repartition and coalesce, and when has that difference hurt you?"

Weak
"repartition increases partitions, coalesce decreases them."
Good
"repartition does a full shuffle and gives even partitions; coalesce avoids the shuffle by merging, so it's cheaper but uneven."
Senior
"The mechanical difference is the shuffle, but the operational difference is that coalesce can'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, repartition works; if I want even sizes without picking a number, a REBALANCE hint lets AQE target a size instead."
🎯 Scenario 3

"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 EXPLAIN to 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 trusting EXPLAIN output 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 an explode downstream, and won't help when one input file is enormous before any shuffle exists. Those are still mine to fix."
🎯 Scenario 4

"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."
🎯 Scenario 5

"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 run ANALYZE 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."
🎯 Scenario 6

"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_HASH hint on the specific offending join."
🎯 Scenario 7

"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."
🎯 Scenario 8

"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."
🎯 Scenario 9

"Your job reads a 500 GB table but only needs one day of it. How do you confirm pruning is working?"

Weak
"The WHERE clause 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 FORMATTED or the SQL tab I look at the scan node for three fields: PartitionFilters should carry the date predicate — that's directory-level pruning and it's the big one; PushedFilters should carry any other predicates that can be evaluated against row-group statistics; and ReadSchema should list only the columns I actually use. Then I check the metrics: number of partitions read, number of files read, and bytes read. If PartitionFilters is 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."
🎯 Scenario 10

"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 REBALANCE before 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."
🎯 Scenario 11

"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."
🎯 Scenario 12

"What's the difference between spark.default.parallelism and spark.sql.shuffle.partitions?"

Weak
"They're basically the same thing."
Good
"default.parallelism applies to RDD operations; sql.shuffle.partitions applies 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 from spark.sql.shuffle.partitions, defaulting to 200 in Apache Spark, and with AQE on that's the initial count before coalescing. spark.default.parallelism is 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."
🎯 Scenario 13

"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."
🎯 Scenario 14

"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."
🎯 Scenario 15

"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."
🎯 Scenario 16

"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(*) DESC and 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."
🎯 Scenario 17

"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."
🎯 Scenario 18 · the closing question

"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."
✦ ✦ ✦
§ Part XXVIII · configuration

Configuration reference, with context.

🚨 Read this before the tables

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

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.sql.shuffle.partitionsPartitions produced by a SQL/DataFrame shuffle200Inspect 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.parallelismDefault partitions for RDD operationstotal 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.maxPartitionBytesTarget bytes per read partition128 MBInspect 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

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.sql.adaptive.enabledMaster switch for runtime re-planningtrue (3.2.0+) false in 3.0/3.1Inspect 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.enabledMerge small post-shuffle partitionstrueInspect when task counts are enormous. Helps by making a generous initial partition count safe. Hides nothing.
spark.sql.adaptive.advisoryPartitionSizeInBytesTarget partition size for coalescing and skew splitting64 MBInspect 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.enabledSplit skewed shuffle partitions in joinstrueInspect 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 / …ThresholdInBytesRelative and absolute conditions for "skewed" values have changed across 3.xread from your Environment tabInspect 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.enabledRead shuffle output locally after a runtime broadcast conversiontrueInspect rarely. Helps automatically. Hides nothing.
spark.sql.adaptive.autoBroadcastJoinThresholdRuntime broadcast threshold used by AQEfollows spark.sql.autoBroadcastJoinThresholdInspect 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

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.sql.autoBroadcastJoinThresholdEstimated build-side size below which broadcast is chosen; -1 disables10 MBInspect 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.broadcastTimeoutSeconds to wait for a broadcast to be built300Inspect 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

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.executor.memoryJVM heap per executor1gInspect 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.coresConcurrent tasks per executor1 (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.fractionShare of usable heap for execution + storage0.6Inspect 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.storageFractionFloor of unified memory guaranteed to cached blocks0.5Inspect 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.memoryDriver JVM heap1gInspect on any driver OOM, huge task counts, or big broadcasts. Helps when the driver's job is legitimately large. Hides a collect().
spark.driver.maxResultSizeCap on serialized results returned to the driver; 0 = unlimited1gInspect 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

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.executor.memoryOverheadContainer memory outside the heapcomputed from executor memory with a floor — verify the factor and floor for your versionInspect 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.memoryExplicit budget for Python worker memorynot setInspect 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.sizeOff-heap execution memoryfalse / 0Inspect 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.memoryOverheadSame, for the drivercomputed as aboveInspect when the driver is killed rather than throwing. Helps on Python-heavy driver work. Hides a toPandas().

Dynamic allocation

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.dynamicAllocation.enabledScale executors with demandfalse often true on managed platformsInspect when utilisation is uneven across stages. Helps for variable-width jobs. Hides nothing, but adds ramp-up latency.
…minExecutors / …maxExecutors / …initialExecutorsBounds and starting point0 / infinity / = minInspect 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.
…schedulerBacklogTimeoutBacklog duration before requesting more executors1sInspect rarely. Helps lengthened on bursty workloads that over-request. Hides nothing.
…executorIdleTimeoutIdle duration before releasing an executor60sInspect when paying for long idle tails. Helps lowered for cost, raised when executors thrash. Hides the tail's real cause.
…shuffleTracking.enabledKeep executors that still hold needed shuffle datafalseInspect when dynamic allocation causes fetch failures. Helps where no external shuffle service exists. Hides nothing.

Networking and shuffle transport

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.network.timeoutDefault network timeout, including heartbeat detection120sInspect on heartbeat losses. Helps in genuinely high-latency environments. Hides long GC pauses and unhealthy hosts — the classic "larger waiting room".
spark.executor.heartbeatIntervalExecutor liveness and metrics reporting cadence10sInspect only alongside the timeout. Helps almost never. Hides nothing; must stay well below the timeout.
spark.shuffle.io.maxRetries / spark.shuffle.io.retryWaitFetch retry policy3 / 5sInspect 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.maxSizeInFlightConcurrent shuffle fetch volume per reducer48mInspect on reduce-side memory pressure. Helps lowered to relieve memory, raised on fast networks. Hides oversized partitions.
spark.task.maxFailuresTask attempts before the stage fails4Inspect when failures recur. Helps essentially never. Hides a deterministic bug, and pays for it four more times.
spark.stage.maxConsecutiveAttemptsStage retries after fetch failures4Inspect during retry storms. Helps essentially never. Hides the first executor death, which is the whole story.

Speculation

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.speculationDuplicate slow tasks onto other executorsfalse frequently enabled by platformsInspect 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 / …intervalHow much slower, after what fraction complete, checked how often1.5 / 0.75 / 100msInspect 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

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.sql.files.openCostInBytesModelled cost of opening a file, used when packing small files4 MBInspect 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 / …maxPartitionNumFloor/ceiling on read partitions availability variesunsetInspect when byte-based splitting gives absurd task counts. Helps as a guard rail. Hides the underlying layout.
spark.sql.sources.parallelPartitionDiscovery.threshold / …parallelismWhen and how widely to distribute directory listing32 / 10000Inspect 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.enabledEnables storage-partition-aware planning for V2 sourcesfalseInspect 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

PropertyPurposeApache defaultWhen to inspect · when changing helps · when it merely hides
spark.sql.cbo.enabled / spark.sql.cbo.joinReorder.enabledCost-based optimization and join reorderingfalse / falseInspect 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.dirDurable record of the run for the History Serverfalse / — usually enabled by platformsInspect today, not during an incident. Helps by making every past run reproducible. Hides nothing; its absence is what hides everything.
spark.sql.ansi.enabledANSI SQL semantics: strict casts, arithmetic overflow errors enabled by default in Spark 4.0; disabled in 3.xsee your versionInspect 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.maxObject serialization and its buffer ceilingverify in the Environment tabInspect on serialization failures. Helps raised when one object is legitimately large. Hides a data-model problem — ask why a single object is multiple megabytes.
✦ ✦ ✦
§ Part XXIX · observability

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.

▸ FOUR STREAMS, ONE DASHBOARD SPARK driver + executors and your own job code emit input rows, input bytes, output rows, output bytes — four lines of code, the highest leverage in the whole diagram Event logs every task, every metric History Server the UI, after the fact REST API JSON · scriptable Metrics sink JVM · executor · JMX Prometheus / agent time series, retained Host metrics CPU · disk · network Run metadata queue · retries · params PIPELINE HEALTH DASHBOARD one row per run, ninety days deep ratio-based alerts, not absolutes this is what turns two hours into two minutes Build it in this order: (1) volume counters from your own code — cheapest, highest value; (2) event logging enabled and retained; (3) stage/task quantiles pulled from the REST API after each run; (4) host and JVM metrics. Most teams do (4) first and wonder why incidents are still slow to diagnose.
Diagram 30 · Observability architecture. Four streams. The cheapest one is the one you write yourself.

The capture list

Per run, one row, stored somewhere queryable:

GroupFieldsSource
Identityapplication id, pipeline id, run id, Spark version, cluster/config profile, git commitOrchestrator + SparkContext
Volumeinput rows, input bytes, output rows, output bytes, input file count, output file countYour job code, and the SQL tab's scan/write metrics
Shapetask count, stage count, task p50/p95/max duration, max ÷ p50 ratio for the top stagesREST API taskSummary
Movementshuffle read bytes, shuffle write bytes, spill memory, spill diskREST API stages
HealthGC time, executor count over time, failed tasks, retried tasks, executor losses, stage attemptsREST API + event log
Costexecutor-hours, cluster minutes including startup, estimated cost, cost per TBOrchestrator + platform billing Platform-dependent
Planphysical plan fingerprint (normalised hash)Captured from the query execution at runtime
🛠 Start here — the smallest useful version
# 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.

§ Part XXX · prevention

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 STATISTICS on 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 maxExecutors per 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
🧠 The organisational version of this handbook

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.

✦ ✦ ✦
§ The three master artifacts

Print these. Pin them.

1 · The one-page decision tree

▸ PIPELINE SLOW OR FAILED — one page, every branch PIPELINE SLOW OR FAILED No application ID Application ran / is running Completed, but slower than usual ORCHESTRATION FAMILY scheduler · provisioning · quota permissions · dependencies · JARs Python env · driver init · bad config Evidence: orchestrator + cluster- manager logs. Spark UI has nothing. REGRESSION FAMILY compare to baseline, metric by metric input ↑ → data event (Part XXI) shuffle ↑ on flat input → plan flip output ÷ input ↑ → fan-out files ↑ → layout change exec-hours ↑ with flat runtime → cost FAILED RUNNING, SLOW DRIVER FAMILY collect / toPandas huge broadcast millions of tasks file listing maxResultSize giant plan → Part X EXECUTOR FAMILY exception → heap OOM exit 137 → container kill exit 143 → preemption fetch failed → find first death heartbeat → GC or CPU disk full → scratch volume → Parts IV, V, XVIII, XIX Open the dominant stage. Compare MAX vs MEDIAN. MAX ≫ MEDIAN input also ≫ ? YES → SKEW hot key · null · sentinel · explode NO → STRAGGLER bad node · GC · noisy neighbour MAX ≈ MEDIAN spill > 0 ? YES → UNDER- PARTITIONED CPU ≈ duration ? YES → CPU-BOUND UDF · regex · JSON NO → I/O-BOUND TASKS IDLE slots free but work not flowing → driver? → listing? → allocation? → locality? Parts X, XII, XVI ▸ ROOT-CAUSE FAMILIES — every Spark incident lands in one of these nine 1 · ORCHESTRATION — it never became a Spark problem 2 · DATA — volume, distribution, cardinality, layout 3 · PLAN — join strategy, pruning, pushdown, exchanges 4 · DISTRIBUTION — skew and partition sizing 5 · MEMORY — heap, container, Python, driver 6 · CPU — per-row work and optimizer barriers 7 · I/O — files, listing, object-store latency 8 · INFRASTRUCTURE — hosts, network, disk, preemption 9 · COST — right answer, wrong price If you cannot name which family you are in, you are not ready to change a configuration. Go back to the evidence.
Diagram 31 · The one-page decision tree. From the page to a named root-cause family.

2 · The Spark UI cheat sheet

What to look at, for what symptom, and what a bad value means.

Symptom / questionWhere to lookMetricInterpretation
Is this even a Spark problem?OrchestratorQueued vs started vs endedPipeline time minus Spark time is someone else's problem
Where is the time going?Jobs tab → StagesStage duration as a share of jobOne stage over ~60% is the incident; ignore the rest
Skew or not?Stage → Summary MetricsDuration and Shuffle Read at p50 vs maxBoth high → skew. Duration high, read normal → straggler
Are partitions the right size?Stage → Summary MetricsSpill (memory), Spill (disk)Any spill at scale → partitions exceed per-task execution memory
Is memory healthy?Executors tabPeak JVM / execution / storage memoryPeak near budget → spill imminent; heap low with kills → non-heap problem
Is GC hurting?Executors tabGC Time ÷ Task TimeRising fraction, or high absolute → allocation or cache pressure
Compute or wait?Stage → Summary MetricsCPU time ÷ duration; Shuffle Read Blocked TimeCPU ≈ duration → compute-bound. High blocked → network / fetch
Is the driver the bottleneck?Jobs timeline + ExecutorsGaps with no active stage; active tasks ≪ slotsListing, plan compilation, commit, or too many tiny jobs
Are we reading too much?SQL tab → scan nodeInput size, files read, partitions read, PartitionFilters, ReadSchemaEmpty PartitionFilters → pruning lost. Wide ReadSchemaSELECT *
Are we shuffling too much?Stage listShuffle write ÷ input bytesRatio ≫ 1 → the plan is moving more than it should
Which join did we get?SQL tab (not EXPLAIN)Join node type + build-side sizeAQE may have changed it; only the final plan is authoritative
Did AQE act?SQL tabAQEShuffleRead node metricsShows coalescing and skew-split decisions with counts
Did we fan out?SQL tabOutput rows on the join / Generate node vs its inputsA clean multiple → duplicate keys. A wild multiple → explode
Is anything failing quietly?Stages tabFailed tasks, stage attempt numberAttempt > 1 means work was paid for twice
Are executors dying?Executors tabRemoved executors and removal reasonsEarliest death is the story; everything after is a consequence
Is cache helping?Storage tabFraction cached, size in memory, size on diskBelow 100% cached means eviction and recomputation
What did we actually run with?Environment tabAll effective configurationThe 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 by chain
  • 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

  1. Name the family — orchestration, data, plan, distribution, memory, CPU, I/O, infrastructure, cost.
  2. Write a falsifiable hypothesis naming a mechanism, not a resource.
  3. Predict which metric will move, and by roughly how much.
  4. Change one variable.
  5. Measure the same metrics you collected above — no others.
  6. Confirm the mechanism, not just the runtime. Spill went to zero. Max ÷ median fell. Shuffle bytes dropped. Input bytes dropped.
  7. Record executor-hours alongside runtime. A faster job that costs three times as much is a decision, not a win.
  8. Document what you changed and which measurement justified it.
  9. Add the detector that would have caught this before the page.
🔎 The collection script
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.

✦ ✦ ✦
§ Appendix · sources

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.

🚨 Two rules for using these links

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 questionApache Spark pageCovers, and which parts here rely on it
What is this property and what is its default?ConfigurationEvery 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 TuningShuffle 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 UIJobs, 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 InstrumentationEvent 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 SparkMemory 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 SchedulingDynamic 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 KubernetesCluster-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?EXPLAINThe 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 hintsTable 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 FilesColumnar 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 GuideWhere 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 GuideThe 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 notesThe 4.x baseline this handbook is written against. Release notes for other versions follow the same URL pattern.
PySpark specificsPySpark documentationArrow-based conversion, pandas UDF types and their batching, and the Python worker model behind Parts XI and V.

Version notes — what this handbook assumes

AreaWhat this page saysThe version caveat
AQE on by defaultTreated as on throughout, and the reason partition tuning is a size target rather than a countTrue 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 thresholdsDescribed by mechanism — a relative factor and an absolute byte threshold, both of which must be exceededThe default values have moved across the 3.x line, which is why no number is quoted here. Read them from your Environment tab.
REBALANCE hintRecommended for even output sizingAvailable from 3.3. On earlier versions use repartition and accept that you are choosing the number yourself.
Storage Partition JoinPresented as conditional and off by default, behind the V2 bucketing configuration familyIntroduced 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 modeFlagged as a migration eventEnabled 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 loggingMentioned as a 4.x direction that makes logs queryableWhether 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.serializerDeliberately not statedRead it from the Environment tab. This handbook does not quote it because the answer has not been stable enough to quote.
Arrow for PySpark conversionDescribed by effect, not by default valueThe default for spark.sql.execution.arrow.pyspark.enabled is version-dependent. Efficiency is not capacity either way — see Part X.
📊 The one command that beats every citation on this page
# 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.

🧠 On the numbers in this handbook

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.

✦ ✦ ✦
§ the one sentence

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.

🛠 If you keep four things from this page
  1. Find where the time or failure occurs before you touch a configuration. Level first, cause second, fix third.
  2. Compare max against median. That single ratio separates skew from under-partitioning from stragglers, and those three have almost no fixes in common.
  3. Capture volume and plan fingerprints per run. Most "Spark is slow" incidents are data incidents, and this turns them into a one-query answer.
  4. 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.

← Back to Practice · Q&A  ·  ↑ Top