Two hundred tasks finished in a minute and one has been running for an hour. Executors dying with OOM. A job that writes forty thousand files. Diagnose from the symptom, then fix the cause.
199 tasks done in a minute and one running for an hour. What skew looks like in the UI, and the three fixes in the order you should try them.
OOM and spill
6
Which process ran out, what spill is telling you, and why the answer is almost never to raise executor memory.
It ran, and the number is wrong
6
Rows appearing, rows disappearing, and joins that silently change the count. The bugs that produce a green job and a wrong answer.
Too many files, too few tasks
4
Forty thousand output files, a job that uses two cores, and a stage that spends its time listing rather than reading.
Evergreen · asked verbatim
3
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation — because the two fail separately, and a candidate who can read a plan can still stall on “define a stage”.
01 / 23
Data skewShuffle mechanics
A join between events and accounts has 200 tasks; 199 finished in under a minute and one has been running for 40. The plan is below. What is happening?
The setup — predict the table before reading on
spark.sql.autoBroadcastJoinThreshold-1
The tables
events(event_id int, account_id string, value int)
5,000 events. 80% of them belong to acct_0.
events
event_id
account_id
value
0
acct_0
0
1
acct_0
3
2
acct_0
6
3
acct_0
9
accounts(account_id string, plan string)
10 accounts.
accounts
account_id
plan
acct_0
free
acct_1
pro
acct_2
free
acct_3
pro
The code — what does Spark do with it?
out = (spark.table("events")
.join(spark.table("accounts"), "account_id")
.groupBy("plan").agg(F.sum("value").alias("total")))
The code — predict the output before reading on
out = (spark.table("events")
.join(spark.table("accounts"), "account_id")
.groupBy("plan").agg(F.sum("value").alias("total")))
Why they ask this
It is the single most common Spark performance incident, and the diagnosis has a distinctive fingerprint in the UI that candidates should be able to describe.
Say this
Skew. One account_id is 80% of the events, so hashing by that key puts most rows in one partition — and one task therefore does most of the work while the rest idle.
The reasoning
A shuffle assigns rows to partitions by hashing the join key. Every row with the same key lands in the same partition, so if one key holds 80% of the rows, one partition holds 80% of the data regardless of how many partitions you configure. That partition is one task, and one task runs on one core.
The fingerprint in the Spark UI is unmistakable once you know it: open the stage, sort tasks by duration, and look at the summary metrics. Skew shows as a max shuffle-read size and a max duration that are orders of magnitude above the median — not a long tail, a single outlier. If instead every task is slow, that is a sizing problem and a completely different investigation.
The plan below shows the sort-merge join that produced it: both sides hash-partitioned on account_id, both sorted, and the merge happening per partition. Nothing in the plan is wrong — the plan is correct and the data is uneven, which is why this is a data problem wearing a performance costume and why reading the plan alone will never reveal it.
What it actually returns 3 shuffles, run on Spark 4.2
Three Exchanges. acct_0 is 80% of the events, and hashing sends all of it to one partition.
"Increase spark.sql.shuffle.partitions so the work spreads out." Every row with that key hashes to one partition however many there are. More partitions makes the other 199 smaller and leaves the hot one exactly as it was.
They’ll ask next
Which do you try first — AQE, broadcast, or salting — and why in that order?
AQE is not enough and the other side is too big to broadcast. Here is the salted version. Walk through why it works and what you have taken on.
The setup — predict the table before reading on
spark.sql.autoBroadcastJoinThreshold-1
The tables
events(event_id int, account_id string, value int)
5,000 events. 80% of them belong to acct_0.
events
event_id
account_id
value
0
acct_0
0
1
acct_0
3
2
acct_0
6
3
acct_0
9
accounts(account_id string, plan string)
10 accounts.
accounts
account_id
plan
acct_0
free
acct_1
pro
acct_2
free
acct_3
pro
The code — what does Spark do with it?
S = 8
e = (spark.table("events")
.withColumn("salt", (F.rand(42) * S).cast("int"))
.withColumn("skey", F.concat_ws("#", F.col("account_id"), F.col("salt"))))
a = (spark.table("accounts")
.withColumn("salt", F.explode(F.array(*[F.lit(i) for i in range(S)])))
.withColumn("skey", F.concat_ws("#", F.col("account_id"), F.col("salt"))))
out = (e.join(a, "skey").groupBy("plan").agg(F.sum("value").alias("total")))
The code — predict the output before reading on
S = 8
e = (spark.table("events")
.withColumn("salt", (F.rand(42) * S).cast("int"))
.withColumn("skey", F.concat_ws("#", F.col("account_id"), F.col("salt"))))
a = (spark.table("accounts")
.withColumn("salt", F.explode(F.array(*[F.lit(i) for i in range(S)])))
.withColumn("skey", F.concat_ws("#", F.col("account_id"), F.col("salt"))))
out = (e.join(a, "skey").groupBy("plan").agg(F.sum("value").alias("total")))
Why they ask this
Salting is the last-resort fix and candidates usually describe it vaguely. Writing it out exposes the part people forget — that the small side has to be replicated.
Say this
Append a random salt to the skewed side's key and explode the other side across every salt value, so the hot key becomes N keys spread over N partitions. What you take on is an N-fold copy of the small side and a join key nobody can read.
The reasoning
The mechanism is to break one hot key into N. Adding a random salt in 0..N-1 to each event's account_id turns acct_0 into acct_0#0 through acct_0#7, which hash to eight different partitions — so the 80% is spread across eight tasks instead of one.
The half people forget is that the other side has to match all of them. If events carry acct_0#3, the accounts row for acct_0 must also exist as acct_0#3 or the join loses it. So the small side is exploded across every salt value, which multiplies it by N. That is affordable for a dimension and it is why salting is only viable when one side is small — at which point broadcasting was often available anyway, which is exactly why this is the third option rather than the first.
The costs are worth naming plainly. The small side is N times bigger. The join key is now a synthetic string nobody reading the code will understand without the comment. If you aggregate afterwards you have to strip the salt and re-aggregate. And N is a tuning parameter that has to be revisited as the skew changes. It works and it is real complexity, which is why AQE's automatic skew handling is the better answer whenever it suffices.
What it actually returns 3 shuffles, run on Spark 4.2
The small side exploded across 8 salts so every salted key still finds its match.
"Salt just the big side and join normally." Then acct_0#3 has nothing to match — the accounts row is still plain acct_0. Replicating the small side across every salt value is what makes the join correct, and omitting it silently drops rows.
They’ll ask next
You salted and now need per-account totals. How do you get back to them?
A silently smaller row count is the most dangerous class of Spark bug, because nothing fails and the number just gets smaller.
Say this
They changed a left join to an inner join. Every fact row whose customer is missing from the dimension now disappears instead of appearing with nulls — and nothing anywhere reports it.
The reasoning
An inner join keeps only matched rows. A left join keeps every row from the left and fills the right's columns with null where there is no match. Swapping one for the other is a one-word edit that changes the semantics of the result, and neither version errors.
The two runs below return different rows, which the harness asserts — that is the whole point of publishing the pair. The broken version simply has fewer rows, and the only signal available is the count itself. If nothing is watching the count, this ships.
There is a second, subtler version of the same bug worth raising: a left join followed by a filter on a right-side column silently becomes an inner join, because the null rows fail the predicate. WHERE tier = 'pro' after a left join removes every unmatched row exactly as an inner join would. The fix is to put the condition in the join's ON clause rather than in the filter, which keeps the preservation semantics intact — and it is worth checking for whenever a left join's row count looks like an inner join's.
See it 0 shuffles, run on Spark 4.2
Same data, one word different, 400 rows fewer. The harness asserts they differ.
"The dimension must have lost rows." It may have — and the immediate cause here is the join type, and the check that distinguishes them is comparing the fact row count before and after the join rather than investigating the dimension.
They’ll ask next
Your left join gives an inner join's row count and the join type is right. What do you look at?
o = spark.table("orders").filter(F.col("order_id") < 20)
p = spark.table("products").select("product_id", "category").dropDuplicates(["product_id"])
out = o.join(p, "product_id").groupBy("region").agg(F.sum("amount").alias("total"))
The job as found
o = spark.table("orders").filter(F.col("order_id") < 20)
p = spark.table("products").select("product_id", "category").union(
spark.table("products").select("product_id", "category"))
out = o.join(p, "product_id").groupBy("region").agg(F.sum("amount").alias("total"))
Fan-out is the inflating counterpart to the dropping join, and it is the most common cause of a revenue figure that is suddenly wrong in the direction people notice.
Say this
The dimension gained duplicate keys, so each order matched more than one product row and its amount was counted once per match. The fix is deduplicating the dimension — and better, asserting its key is unique.
The reasoning
A join emits one row per matching pair. If products has two rows for a product_id, every order for that product produces two rows, and a sum over amount counts it twice. Nothing is wrong with the join or with Spark; the dimension stopped being unique on its key.
The diagnostic is one query and worth having as a reflex: compare count(*) against count(distinct product_id) on the dimension. If they differ, any join to it will fan out. Running that as an assertion in the pipeline — rather than as an investigation after finance calls — is what makes this bug a one-time event instead of a recurring one.
The fix in the code below is dropDuplicates on the key, and it is worth being honest that it is a patch rather than a repair. It picks an arbitrary row among the duplicates, which is only safe when they are genuinely identical. If they differ, you have a modelling problem — the grain of the dimension is not what you thought — and choosing which row survives is a business decision rather than a technical one.
See it 2 shuffles, run on Spark 4.2
Same orders both times. The dimension is duplicated in one, and the totals double.
"Add distinct() to the final result." That removes rows the aggregation already double-counted — the sum is computed before you deduplicate, so the number stays wrong and now you have hidden the evidence.
They’ll ask next
The duplicate product rows are not identical. Which one should win, and who decides?
Partitioning, repartition vs coalesceSmall files problem
A job that used to take four minutes now takes forty, and the Spark UI shows a single task in the main stage. Two versions are below — which one is which?
out = (spark.table("orders")
.groupBy("region").agg(F.sum("amount").alias("total"))
.coalesce(1))
Why they ask this
coalesce's upstream propagation is the classic silent performance regression, and seeing both plans side by side is what makes it obvious.
Say this
The slow version has repartition(1) before the aggregation, so every row is funnelled through one task before any work happens. The fast one aggregates in parallel and coalesces the small result afterwards.
The reasoning
Both versions produce one output partition and both have one Exchange, so a shuffle count tells you nothing here — which is a useful lesson in itself. What differs is *where* the narrowing sits relative to the work.
repartition(1) before the groupBy sends all 2,000 rows into a single partition and the aggregation then runs with one task. On real data that is the entire dataset through one core, and adding executors does nothing at all. coalesce(1) after the aggregation lets the grouping run across all partitions and merges the three resulting rows, which costs nothing.
The general rule is that narrowing belongs after the expensive work, and the specific trap is that coalesce propagates upstream — coalesce(1) placed *before* expensive work has the same effect as repartition(1), silently, because it introduces no stage boundary. If you must reduce to one partition before something expensive, repartition is the one to use, precisely because its shuffle creates the boundary that keeps the upstream parallel.
What it actually returns 1 shuffle, run on Spark 4.2
Same rows, same shuffle count. One version does the aggregation with a single task.
"Add more executors — the cluster is underused." The cluster is underused because the job asked for one partition. Executors cannot help when there is only one task to schedule.
They’ll ask next
You need one output file and the work before it is expensive. Which call, and where?
A job fails with java.lang.OutOfMemoryError. Before changing any setting, how do you work out whether it was the driver or an executor, and why does that decide everything else?
Why they ask this
'Spark ran out of memory' is not a diagnosis. The two processes fail for unrelated reasons and have unrelated fixes, and raising the wrong one wastes a day.
Say this
Read where the stack trace came from — driver logs or an executor's. Driver OOM means you pulled data to it; executor OOM means one partition did not fit in a task. The fixes have nothing in common.
The reasoning
Driver OOM comes from data arriving at the driver: collect() or toPandas() on something large, a broadcast whose build side exceeded the estimate, or an enormous plan built by thousands of iterative transformations. The fix is to stop pulling data — write to storage and read it back, or aggregate before collecting — not to raise driver memory, which only moves the threshold.
Executor OOM means a single task could not fit its partition's working set. That is almost always skew: 199 partitions fit and one did not. It can also be a wide aggregation with high cardinality, or an explode multiplying rows inside a task. The fix is to make partitions smaller or more even, not to buy bigger executors — raising memory to accommodate the largest partition means paying for it on every executor for the sake of one task.
The reason the distinction matters so much is that the instinctive fix — raise spark.executor.memory — helps neither case properly. For a driver OOM it is the wrong process entirely. For an executor OOM it treats the symptom and leaves you with an expensive cluster that fails again when the skew worsens. Reading which process threw is thirty seconds of work and it redirects the entire investigation.
The formulations
Driver OOM: stop moving data to the drivership
df.write.parquet(path) # instead of collect()
aggregate first, collect a summary
The driver is one process; anything that scales with your data should never arrive there.
Executor OOM: make partitions smaller or more evenship
AQE skew handling; raise shuffle partitions;
fix the skewed key
Addresses the cause. One partition that does not fit is a distribution problem, not a sizing one.
Raise spark.executor.memory until it passesavoid
--executor-memory 64g
Pays for the largest partition on every executor and fails again the next time the skew grows.
The answer most people give
"Just increase executor memory." It is the reflex and it is wrong for a driver OOM entirely, and for an executor OOM it buys headroom rather than fixing the distribution that caused it.
They’ll ask next
The stack trace is from the driver and there is no collect() in the code. What else could it be?
A stage shows 40 GB of spill (memory) and 12 GB of spill (disk) but the job succeeds. Is that a problem, and what are the two numbers actually measuring?
Why they ask this
Spill is the warning that precedes an OOM and most people never look at it. The two columns confuse everyone, and one of them is not what it appears to be.
Say this
It is a problem — spill means tasks exceeded their memory and wrote to disk, which is slow. Spill (memory) is the deserialised size of what was spilled; spill (disk) is the compressed bytes actually written.
The reasoning
When a task's working set outgrows its share of execution memory, Spark writes part of it to local disk and reads it back — that is spill, and it is the graceful degradation that stops an OOM. Succeeding with spill is better than failing without it, and it is much slower than not spilling.
The two columns measure the same event twice. Spill (memory) is how large that data was in memory before it was written; spill (disk) is how many bytes hit the disk after serialisation and compression. The ratio is a compression ratio, not two separate spills, and people frequently read the pair as if 52 GB moved.
Fixing it means either giving tasks less to hold or more room to hold it, and less is usually right. More partitions makes each task's share smaller; fixing skew makes them even; and aggregating or projecting earlier means less is carried into the operator that spilled. Raising spark.memory.fraction is the lever people reach for and it steals from storage memory, so it makes caching worse to make execution better — occasionally correct, rarely the first move.
The formulations
Increase shuffle partitions so each task holds lessship
spark.sql.shuffle.partitions = 800 # or let AQE coalesce
Directly reduces the per-task working set, which is what spilled.
Reduce what is carried into the operatorship
select only needed columns; aggregate before the wide op
Less data to hold at all. The fix that also makes everything else cheaper.
Raise spark.memory.fractionworks
spark.memory.fraction = 0.8
Takes room from storage to give it to execution. Sometimes right, rarely the first thing to try.
The answer most people give
"Spill is fine, the job finished." It finished slowly, and it is the last warning before an OOM — the same stage on 20% more data may not have room to spill into.
They’ll ask next
Spill is concentrated in three tasks out of 200. What does that tell you?
A job fails with 'Could not execute broadcast in 300 secs'. What actually timed out, and what are your options?
Why they ask this
It is a distinctive error with a specific cause, and the recommended fix in most search results — raise the timeout — is the worst of the three options.
Say this
Collecting the build side to the driver and shipping it to every executor did not finish in time, which almost always means it is far larger than the optimizer estimated. Raising the timeout treats the clock, not the size.
The reasoning
A broadcast is collect-then-distribute: the driver pulls the whole build side, builds a relation from it, and ships it to every executor. The timeout covers that whole sequence, so exceeding it means the data is big, the driver is slow, or the cluster is wide enough that distribution takes real time.
The usual root cause is a size estimate that was wrong. Without table statistics Spark estimates from compressed Parquet file sizes, which systematically under-reports the in-memory size — a table that looked like 8 MB can be several times that once deserialised. It is also common after a chain of filters and joins, where the optimizer's estimate of an intermediate has drifted far from reality.
Three options, in order. Stop broadcasting it: remove the hint or lower the threshold, and accept a sort-merge join, which is slower per query and does not fail. Make the estimate right: run ANALYZE TABLE so the decision is based on real statistics rather than file size. Or enable AQE, which decides from the *actual* shuffled size at runtime and will simply not attempt a broadcast that does not fit. Raising spark.sql.broadcastTimeout is fourth, because it gives a too-large broadcast more time to exhaust the driver.
The formulations
Let AQE decide from real runtime sizesship
spark.sql.adaptive.enabled = true
Converts to broadcast only when the measured shuffled side actually fits. No hard-coded assumption.
ANALYZE TABLE so the estimate is realship
ANALYZE TABLE dim COMPUTE STATISTICS FOR ALL COLUMNS
Fixes the cause: the optimizer was deciding from compressed file size, which under-reports.
Raise the broadcast timeoutavoid
spark.sql.broadcastTimeout = 1200
Gives a broadcast that is too large more time to exhaust the driver instead.
The answer most people give
"Increase spark.sql.broadcastTimeout." It converts a fast failure into a slow one and often into a driver OOM. The timeout is reporting a size problem, not a scheduling one.
They’ll ask next
You remove the hint and the sort-merge join is too slow. What now?
The stage detail shows GC time at roughly a third of task time. What causes that in Spark specifically, and what do you change?
Why they ask this
GC time is visible in the UI and almost nobody uses it. The Spark-specific causes are narrow enough to enumerate.
Say this
Too many objects on the heap relative to its size — usually caching deserialised data, very large executors, or dropping into RDDs of Java objects instead of staying in Tungsten's binary format.
The reasoning
The structured API keeps rows in Tungsten's off-heap-style binary format precisely to avoid this: no per-row Java objects means very little for the collector to trace. So high GC time usually means something has put ordinary objects back on the heap — an RDD of case classes or tuples, a UDF materialising collections per row, or a cache using a deserialised storage level.
Executor size is the second cause and it is structural. A very large heap takes longer to collect, so 64 GB executors can show worse pause behaviour than several 16 GB ones even at the same total memory. That is one of the concrete reasons for the conventional advice of moderate executors rather than a few enormous ones.
The fixes follow the causes: stay in the DataFrame API so rows stay binary, use MEMORY_ONLY_SER or MEMORY_AND_DISK_SER when caching large data so cached blocks are one byte array rather than millions of objects, and prefer moderate executor sizes. Tuning the collector itself — switching to G1, adjusting region sizes — is a real lever and it is the last one, because it manages the symptom rather than reducing the allocation.
The formulations
Stay in the DataFrame APIship
df.groupBy(...).agg(...) # Tungsten binary rows
The reason the structured API exists. No per-row Java objects means almost nothing to collect.
Serialised storage levels when caching large dataship
df.persist(StorageLevel.MEMORY_AND_DISK_SER)
One byte array per partition instead of millions of live objects on the heap.
A real lever, and it manages the symptom rather than reducing what is allocated.
The answer most people give
"Give the executor more memory so GC runs less often." A bigger heap usually means longer pauses, not fewer — and it does nothing about the object churn that is creating the work.
They’ll ask next
Your job caches a large DataFrame and GC time tripled. What level are you using?
UDFs vs native vs pandas UDFsRDD vs DataFrame vs Dataset
A UDF is applied and every value in the output column is null. The UDF works when you test it in Python. What is wrong?
Why they ask this
It is a specific, very common PySpark failure with a specific cause, and the silent-null behaviour is what makes it confusing.
Say this
The declared return type does not match what the function returns. Spark casts the result to the declared type and produces null when the cast fails — for every row, with no error.
The reasoning
A Python UDF is registered with a Spark type, and Spark trusts that declaration. If the function returns a Python float and the UDF declares IntegerType, or returns a string where a DoubleType was declared, the conversion fails and the result is null rather than an exception. Every row, silently.
The reason it is so confusing is the asymmetry: the function is correct, the test in Python passes, and the failure only exists at the boundary. numpy types are a frequent culprit — a numpy.int64 is not a Python int, and the conversion can produce null depending on the declared type. Returning a dict where a StructType was declared with different field names does the same thing.
The way to find it is to check the declared type against the function's actual return with a single row, and the way to avoid it is to not be there — a native expression has no declaration to get wrong, and a pandas UDF with type hints makes the mismatch far more visible. When a UDF is genuinely necessary, being explicit about the return type and converting inside the function is what keeps it honest.
The formulations
Express it natively and have no declaration to get wrongship
No boundary, no declared type, no silent cast. The failure mode simply does not exist.
pandas UDF with type hintsship
@F.pandas_udf('double')
def f(s: pd.Series) -> pd.Series: return s * 1.1
The hint and the declaration sit together, so a mismatch is visible where it is written.
Scalar UDF with a mismatched return typeavoid
F.udf(lambda a: a * 1.1, 'int') # float into an int column
Spark casts and yields null on failure — every row, with nothing raised.
The answer most people give
"The UDF must be throwing an exception that Spark swallows." Spark does not swallow exceptions from a UDF — it fails the task. Nulls mean the function returned something that could not become the declared type.
They’ll ask next
Your UDF returns a numpy.float64 and the column is declared double. Does that work?
A daily aggregation puts some events on the wrong day, and the boundary rows are always near midnight. What is happening?
Why they ask this
Timezone handling is where a Spark job quietly disagrees with the source system, and the session-level default makes it environment-dependent.
Say this
Timestamps are being interpreted in the session timezone, which differs from the one the data was written in. Rows near midnight cross a day boundary when converted, so they land in the adjacent partition.
The reasoning
Spark's TimestampType is an instant, and rendering it as a date requires a timezone. That timezone is spark.sql.session.timeZone, which defaults to the JVM's — so the same job produces different daily buckets on a laptop in Europe and a cluster in UTC. Only rows within the offset of midnight move, which is why the symptom looks so specific.
The fix is to set the session timezone explicitly rather than inheriting it. Being explicit means the job produces the same answer everywhere, and it forces the question the bug was really about: which timezone does the business mean by 'day'? For a global product that is usually UTC; for a retailer it may be store-local, which is a per-row conversion rather than a session setting.
The related trap is TimestampNTZ and string timestamps. A timestamp stored as a string and cast on read is interpreted in the session timezone too, so the same shift applies at parse time. And if the source wrote local times with no offset, there is no timezone information to recover — the correct handling has to be agreed with the producer rather than guessed, which makes it a data-contract question as much as a Spark one.
Right when a retailer means store-local midnight rather than one global boundary.
Rely on the JVM defaultavoid
-- no timeZone set; inherits the machine's
Bucketing that changes with the machine, and only near midnight — which is why it takes so long to find.
The answer most people give
"Store everything as a string to avoid timezone issues." The string is parsed in the session timezone as soon as you cast it to compare or truncate, so the shift reappears and you have also lost type checking.
They’ll ask next
The source writes local time with no offset. What can you actually recover?
RDD vs DataFrame vs DatasetPartitioning, repartition vs coalesce
The same job on the same input produces slightly different output between runs. Name three things in Spark that can cause that.
Why they ask this
Non-determinism destroys trust in a pipeline and is hard to chase. The causes are enumerable, which makes it a good diagnostic question.
Say this
An unstable sort with ties, monotonically_increasing_id or rand without a seed, and dropDuplicates or first() choosing arbitrarily among equal rows. All three are stable on small data and diverge at scale.
The reasoning
Ties in an ordering are the most common. row_number over a window whose ORDER BY does not uniquely determine an order picks arbitrarily among tied rows, and which one wins depends on partitioning and task completion. Adding a unique tiebreaker makes it reproducible, and it is the fix most top-N code is missing.
Functions that are non-deterministic by construction are the second: rand() without a seed, and monotonically_increasing_id, whose values depend on partition ids and therefore change if the partitioning changes. Using the latter as a stable key is a specific and common mistake — it is unique within a run and not stable across runs.
Third is any operator that picks arbitrarily: dropDuplicates on a subset of columns, first() and last() in an aggregation without an ordering. All of them return *a* row rather than a defined one. And the reason all three tend to appear late is that they are usually stable on a single-partition test dataset and only diverge once the data is large enough to be partitioned differently between runs.
Unique within a run and not stable across runs — it encodes the partition id.
The answer most people give
"Spark is deterministic, so the input must have changed." Several ordinary operations are explicitly non-deterministic, and they are stable on small data — which is exactly why this surfaces in production rather than in tests.
They’ll ask next
You need a stable surrogate key across runs. What do you use instead?
Small files problemPartitioning, repartition vs coalesce
A daily job writes a partitioned table and the target now holds 40,000 files for 8 GB of data. Where did they come from, and what do you change?
Why they ask this
It is the write-side small-files problem and the arithmetic — tasks times distinct partition values — is what makes the cause obvious.
Say this
Each task writes one file per partition value it holds, so 200 shuffle partitions across 30 dates gives up to 6,000 files a day. Repartition by the partition column before writing so each date is written by one task.
The reasoning
partitionBy chooses the directory layout and has no opinion about file count. Every in-memory partition that contains rows for a date opens a file in that date's directory, so the upper bound is tasks multiplied by distinct values — and with a default of 200 shuffle partitions that number is large before anyone has done anything unusual.
The fix is to align the in-memory partitioning with the write layout: repartition on the same column before writing, so all rows for a date live in one task and one file is produced. The shuffle that costs is far cheaper than the read-side penalty of thousands of small files, which is paid by every consumer on every query.
If one date is too large for a single file, repartition on the date plus a deterministic bucket so it splits a controlled number of ways rather than however many the previous stage happened to leave. And if the table is Delta or Iceberg, a scheduled OPTIMIZE handles compaction after the fact — which is the right answer when you cannot control the writer, and not a substitute for writing sensibly when you can.
The formulations
Repartition by the partition column before writingship
One file per date. The shuffle is cheaper than the read penalty every consumer would pay.
Split large partitions deterministicallyship
df.repartition(8, 'order_date', F.spark_partition_id())
# or a hash bucket column
Controls the split for an oversized date instead of inheriting whatever the last stage left.
Compact afterwards and keep writing as-isworks
OPTIMIZE table -- Delta/Iceberg, on a schedule
The right answer when you do not control the writer; it pays twice when you do.
The answer most people give
"Use coalesce(1) before the write." That collapses the whole stage to one task, so the work before the write becomes single-threaded too — and on a partitioned write it still produces one file per date, from one very slow task.
They’ll ask next
One date is ten times the others. What does repartition('order_date') produce?
Small files problemPartitioning, repartition vs coalesce
A read-and-filter job on a 40-core cluster never runs more than two tasks at once. Nothing is skewed. Why?
Why they ask this
Scan parallelism comes from input splits, not from cluster size, and the fix people reach for — repartition — pays a shuffle for something file layout should do.
Say this
The input is two files, so there are two splits and therefore two tasks. Scan parallelism is set by the source layout, and no amount of cluster does anything about it.
The reasoning
The number of tasks in the first stage comes from the number of input splits. For Parquet that is driven by file count and file size against spark.sql.files.maxPartitionBytes — so two large files give two splits, two tasks and two busy cores while thirty-eight idle.
A large *splittable* file can be divided, so the picture depends on the format. Parquet is splittable by row group; a gzipped CSV is not splittable at all, which means one 20 GB .csv.gz is exactly one task no matter what you do. That is the single strongest practical argument against gzip for large inputs, and it surprises people who assume compression is only a size decision.
The right fix is at the source: write more, appropriately-sized files, or use a splittable format and codec. If you cannot change the producer, lowering maxPartitionBytes produces more splits from the same files, and repartition after the read does work — at the cost of a full shuffle to fix a layout problem, which is why it is the last option rather than the first.
The formulations
Fix the file layout at the sourceship
write ~200 MB files in a splittable format
Parallelism for free on every subsequent read, by every consumer.
Lower maxPartitionBytes when you cannotship
spark.sql.files.maxPartitionBytes = 32m
More splits from the same files, with no shuffle. Works only if the format is splittable.
repartition immediately after readingavoid
spark.read.parquet(path).repartition(200)
A full network shuffle to fix a layout problem, and the scan itself is still two tasks.
The answer most people give
"Add executors so more tasks run in parallel." There are only two tasks. Executors cannot run work that was never split, and the bottleneck is entirely on the read side.
They’ll ask next
The input is one 20 GB gzipped CSV. How many tasks, and what do you do?
A stage fails with FetchFailedException, Spark retries it, and it fails again. What is the actual failure, and why does retrying not help?
Why they ask this
FetchFailed is a distinctive error whose cause is upstream of where it appears, and the retry behaviour confuses people who read it as a network blip.
Say this
A downstream task could not fetch shuffle output because the executor that wrote it is gone — so the files went with it. Spark recomputes the upstream stage, and if the underlying cause is still there, it happens again.
The reasoning
Shuffle output is written to the local disk of the executor that produced it. If that executor dies — OOM, spot reclamation, node loss — its shuffle files become unreachable, and the downstream task reports FetchFailedException. The error surfaces in the *reading* stage while the cause is in the *writing* one, which is why people investigate the wrong place.
Spark's response is to mark the map output as lost and re-run the upstream tasks that produced it. That is why you see a stage retry rather than a straightforward failure. If the reason the executor died is still present — a partition that does not fit, a node under memory pressure, aggressive spot reclamation — the recomputation dies the same way and the cycle repeats until the retry limit.
So treat it as a symptom and look upstream. Check for executor OOM or lost workers around the same time; the real fix is whatever killed the executor. And enable the external shuffle service, which holds shuffle files outside the executor process so losing one no longer loses its output — that alone turns many of these from a cascading failure into a rescheduled task, and it is close to mandatory on spot capacity.
The formulations
External shuffle serviceship
spark.shuffle.service.enabled = true
Shuffle files outlive the executor, so losing one stops meaning recomputing a whole stage.
Fix what is killing the executorship
check for OOM / lost nodes in the same window;
address skew or partition size
The error is downstream of the cause. Whatever killed the writer is the real bug.
Raise the retry countavoid
spark.stage.maxConsecutiveAttempts = 10
Repeats a failure whose cause is unchanged, spending far more compute to reach the same end.
The answer most people give
"It is a transient network error, so retrying should fix it." Occasionally. Far more often the executor holding the shuffle files is gone, and retrying recomputes into the same conditions that killed it.
They’ll ask next
You are on spot instances and this happens daily. What is the first thing you turn on?
AQE and skewJoin are both enabled and one task still runs for an hour. Give two reasons AQE might not be helping.
Why they ask this
AQE is treated as a blanket fix. Knowing where it does not apply is what stops a candidate from stopping the investigation at 'it is enabled'.
Say this
Its skew handling applies to joins, so a skewed groupBy or window partition is untouched. And it only splits partitions that exceed both a size threshold and a multiple of the median — a uniformly large stage never triggers it.
The reasoning
AQE's skew handling targets sort-merge joins specifically: it detects oversized partitions after a shuffle and splits them across tasks, replicating the matching side. If your skew is in a groupBy, a window partitionBy, or a distinct, none of that machinery applies and the hot partition is processed by one task exactly as before.
The thresholds are the second reason. A partition has to exceed skewedPartitionThresholdInBytes *and* be several times the median partition size before it is treated as skewed. A stage where every partition is large has no outlier relative to the median, so nothing triggers even though every task is slow — and that is a sizing problem rather than a skew one, which is worth diagnosing differently.
Two more that catch people: AQE needs a shuffle to observe, so a skewed stage with no Exchange gives it nothing to work with; and the split relies on statistics from the completed map stage, so a very small number of partitions can leave it too little to act on. When AQE genuinely does not apply, you are back to the manual options — broadcast if the other side fits, salt if it does not, or change the key so the aggregation is not on the skewed column.
The formulations
Check whether the skew is even in a joinship
skewJoin covers SMJ only; a skewed groupBy
or window partitionBy is untouched
The first thing to establish, and it redirects the whole investigation when the answer is no.
Compare max against median partition sizeship
stage summary metrics: max shuffle read vs median
Distinguishes real skew from a uniformly oversized stage, which needs a different fix.
Assume enabling AQE was the fixavoid
spark.sql.adaptive.skewJoin.enabled = true # and stop looking
It covers one shape of one operator above two thresholds. Plenty of skew falls outside all three.
The answer most people give
"AQE handles skew automatically, so this must be something else." It handles skew in sort-merge joins, above thresholds, when a shuffle gave it statistics. A skewed window partition meets none of those conditions.
They’ll ask next
The skew is in a groupBy on a hot key. What are your options?
A job is slow and you have the Spark UI open. Give the order you look at things, and say what each screen rules out.
Why they ask this
It is the practical skill behind every other debugging question, and the ordering matters — starting in the wrong place costs an hour.
Say this
Jobs to find the slow one, stages to find the slow stage, then the stage's summary metrics to see whether it is skew, spill or uniformly slow. The SQL tab last, to tie it back to the plan.
The reasoning
Start at Jobs to see which action is slow — a script with four writes has four jobs, and narrowing to one immediately halves the search. Then Stages, sorted by duration, to find the stage inside it. Everything before this is triage.
The stage detail page is where the diagnosis happens, and the summary metrics table is the part to read. Compare the max against the median for duration, shuffle read size and spill: a max far above the median is skew; every task slow with high shuffle read is an undersized stage or too much data; high spill in most tasks is a memory problem; high GC time points at object churn. Those four readings cover most incidents and take about a minute.
Then the SQL tab, which shows the query plan annotated with actual row counts and the AQE final plan. That is where you find out that the optimizer estimated a thousand rows and got ten million, or that the broadcast you expected never happened. Going there first is tempting and usually wasteful — the plan tells you what Spark intended, and the stage metrics tell you what actually hurt.
The formulations
Jobs, then Stages, then stage summary metricsship
which action -> which stage -> max vs median
(duration, shuffle read, spill, GC)
Four numbers that separate skew, sizing, memory and object churn in about a minute.
SQL tab for the final plan and real row countsship
SQL tab -> AQE final plan with actual metrics
Where estimate-versus-reality shows up. The right second stop, and a poor first one.
Start with executor logsavoid
grep the executor stderr
Logs are for failures. For a slow job they bury the metrics that would have answered it.
The answer most people give
"Look at the executors tab and see if any are unhealthy." Useful for a failing job and rarely for a slow one — the answer to slowness is nearly always in the stage summary metrics, which most people never open.
They’ll ask next
Max task duration is 60x the median. What is your next move?
Memory management & OOMWide vs narrow transformations
A job explodes an array column and one executor dies while the rest are fine. The arrays average four elements. What happened?
Why they ask this
Averages hide the tail, and explode multiplies within a task — which makes it a distinctive OOM cause that partition counts do not fix.
Say this
The average is irrelevant; some row has an enormous array. explode multiplies rows inside a single task, so one pathological row can generate millions of rows in one partition with nothing to spread them across.
The reasoning
explode is a narrow transformation, which is exactly the problem here. There is no shuffle, so the output rows for a given input row all stay in the task that held it. One record with a million-element array becomes a million rows in one partition, and no partition count anywhere changes that.
The average being four is what makes it hard to spot. Array length distributions in event data are usually long-tailed — a bot session, a bulk import, a retry loop that appended repeatedly — and the maximum is what determines whether a task survives. Checking max(size(col)) rather than avg is the diagnostic, and it takes one query.
The fixes depend on what the giant arrays mean. If they are legitimate, repartition so those rows are spread out before exploding — though since explode is per-row that only helps if several large rows share a partition — or process them separately from the rest. If they are pathological, filter or cap them at ingestion, which is usually the right answer. And if you are exploding only to filter and re-aggregate, the higher-order functions do it without exploding at all, which removes the problem rather than managing it.
The formulations
Check the max array size, not the averageship
df.select(F.max(F.size('items'))).show()
One query that turns 'an executor died' into a specific, fixable data problem.
Removes the multiplication entirely when you were only going to filter and re-aggregate.
Raise executor memoryavoid
--executor-memory 64g
Sized for the worst row on every executor, and it fails again the next time a bigger one arrives.
The answer most people give
"Increase the number of partitions before the explode." explode multiplies within a task, so the giant row still produces all its output in one partition. More partitions spreads the other rows and not that one.
They’ll ask next
The giant arrays are legitimate data. How do you process them?
A DataFrame is cached and the job still reads the source table on every branch. Give two reasons the cache is not being used.
Why they ask this
A cache that silently does not apply is a common and invisible waste, and the two causes are specific enough to check.
Say this
Either it was never materialised — cache() is lazy — or the plan changed after the cache point, so Spark no longer recognises the query as the cached one.
The reasoning
cache() marks a DataFrame; it stores nothing until an action runs, and only for the partitions that action touched. So df.cache() followed by three branches means the first branch computes and caches while the second and third may hit a partially populated cache. The idiom is to follow the cache with a count() to materialise all of it deliberately.
The second reason is plan matching. Spark reuses a cached result when a subtree of the plan matches the cached plan exactly. Add a filter or a column *before* the point you cached, or cache after a transformation and then reference the pre-transformation DataFrame, and the subtrees no longer match — so the cache exists, sits in memory, and is never read. The Storage tab shows it cached while the SQL plan shows a scan, which is the confirming pair of observations.
There is a third cause worth checking: eviction. With MEMORY_ONLY, partitions that do not fit are silently dropped and recomputed on access, so a cache larger than available storage memory behaves like no cache at all while still reporting as cached. MEMORY_AND_DISK spills them instead, which is why it is the sane default.
The formulations
Materialise it deliberatelyship
df.cache(); df.count() # then branch
cache() is lazy. The count is what actually populates every partition.
Cache exactly the subtree the branches shareship
base = df.filter(...).select(...)
base.cache(); base.count() # branches read `base`
Plan matching is exact, so the cached node has to be the node the branches actually reference.
MEMORY_ONLY for something larger than memoryavoid
df.persist(StorageLevel.MEMORY_ONLY)
Partitions that do not fit are dropped and recomputed, while the Storage tab still says cached.
The answer most people give
"If the Storage tab shows it cached, it is being used." Cached and used are different: the plan has to match for a read to hit it, which is why the SQL tab still showing a scan is the thing to check.
They’ll ask next
Storage says 60% cached. What does that mean, and is it helping?
A failed job is rerun and the output table now has more rows than the source. The code is unchanged. What is the most likely cause?
Why they ask this
It is the Spark-side idempotency question, and the answer is a write mode rather than anything about Spark's execution model.
Say this
The write mode is append, so the retry added the rows again on top of what the first attempt had already committed. Nothing in Spark prevents that — the write has to.
The reasoning
Spark gives you task-level fault tolerance, not job-level idempotency. A failed job can leave committed output behind — whole partitions the write already finished — and rerunning with append adds everything a second time. The job is green both times.
The fix is the write mode. Dynamic partition overwrite replaces only the partitions the batch contains, so rerunning a day produces the same table rather than a larger one. Static overwrite would also be idempotent and it replaces the *entire* table, which is a far more destructive default and a bug of its own when someone reruns one day.
On a table format the story is better and worth naming. Delta and Iceberg writes are atomic — a failed write commits nothing, so there is no partial state to reason about — and MERGE or replaceWhere gives idempotence on a key or a predicate. That combination is the reason 'use a table format' is the standard advice for anything that will be retried, which in practice is everything an orchestrator runs.
Replaces only the batch's partitions, so a retry lands on the same table.
MERGE or replaceWhere on Delta/Icebergship
.option('replaceWhere', "dt = '2026-03-01'")
Atomic and idempotent: a failed write commits nothing and a retry replaces rather than adds.
append with a manual cleanup stepavoid
delete the partition by hand, then rerun
Unrepeatable, unreviewed, and one mistyped predicate from deleting the wrong range.
The answer most people give
"Spark is exactly-once, so a rerun cannot duplicate." Spark guarantees each task contributes once to a *successful* job. It says nothing about what a previous failed attempt already committed to storage.
They’ll ask next
You are on plain Parquet and a write failed halfway. What is on disk right now?
A Spark job failed. Before you look at anything else, what are the classes of Spark failure and how do you tell which one you are in?
Why they ask this
Most candidates debug by pattern-matching on a stack trace. Sorting failures into classes first is what makes the search bounded rather than a hunt.
Say this
Resource, shuffle and skew, serialization, data and schema, job logic, I/O and connectivity, and timeout or network. Each has a distinct signature and a different place to look.
The reasoning
**Resource.** `OutOfMemoryError: Java heap space`, `GC overhead limit exceeded`, `ExecutorLostFailure`, "Container killed by YARN for exceeding memory limits", exit code 143. The cause is almost always a partition too large for the task, an oversized broadcast, or too much cached. Look at partition sizes, not at the total data volume.
**Shuffle and skew.** `FetchFailedException`, shuffle block not found, "Failed to send RPC to executor". These usually mean an executor died while others were fetching from it, so the real failure is one class up — but a genuinely enormous shuffle produces them on its own. Sort the stage's tasks by shuffle-read size before anything else.
**Serialization.** `Task not serializable`, `NotSerializableException`, Kryo failures. Caused by a closure dragging something non-serializable to the executors — a database connection, a logger, `self` in a class method. Nothing to do with data volume, and it fails immediately rather than at scale.
**Data and schema.** `AnalysisException` for a missing or ambiguous column, `NumberFormatException`, schema mismatch on read, corrupt Parquet or JSON. These fail on a specific file or a specific column, which is what distinguishes them from the resource class.
**Job logic.** `NullPointerException`, index errors, a UDF raising, division by zero, a join that explodes the row count. The job is doing exactly what you wrote.
**I/O and connectivity.** S3 404 or 403, `BlockMissingException`, "too many open files", cloud throttling. Permissions, missing paths, or rate limits — all environmental.
**Timeout and network.** Heartbeat timeouts, lost executors, `RPC message size exceeded`, fetch timeouts. Frequently a symptom of a long GC pause, which puts you back in the resource class — an executor that stops answering for 120 seconds is declared dead even though it was only collecting garbage.
The value of the taxonomy is what it rules out. "Out of memory" narrows to two classes and tells you to look at partition sizes; `Task not serializable` narrows to one and tells you to look at a closure. Reading the message for its *class* before its detail is what makes the next step obvious.
Look at per-partition size and broadcast size, not total volume.
Shuffle & skewship
FetchFailedException · shuffle block not found
Failed to send RPC to executor
Often downstream of a dead executor. Sort tasks by shuffle read.
Serializationship
Task not serializable · NotSerializableException
A closure captured something that cannot cross to an executor. Fails instantly.
Data & schemaship
AnalysisException · schema mismatch · corrupt record
Points at one column or one file. Not a scale problem.
The answer most people give
"I would increase executor memory and rerun." That is a guess at one of seven classes, and it is the wrong instrument for six of them. It also masks skew: more memory can make a badly-skewed job pass while leaving the actual defect in place.
They’ll ask next
You get a heartbeat timeout and a lost executor. Which class is that really?
Walk me through a time you fixed data skew — from the symptom in the UI to the result, in order.
Why they ask this
It is asked as a story because the ordering is the answer: interviewers want to hear diagnosis before fix, and the cheap fix attempted before the expensive one.
Say this
Confirm skew from the task distribution rather than the runtime, find which key dominates, try broadcast, then AQE skew join, and only salt if neither applies — then verify the max task time actually moved.
The reasoning
**Symptom.** A stage where 197 of 200 tasks finish in seconds and three run for twenty minutes. In the Spark UI, open the stage, sort tasks by duration and read the shuffle-read column: one task reading 8 GB where the median reads 200 MB is skew, and no amount of extra memory will change that. The distinction matters — a stage that is uniformly slow is a different problem entirely.
**Root cause.** Find the offending key by aggregating counts on the join key on the larger side and looking at the top few. It is usually one real value — a dominant country, a default merchant, or a placeholder like `unknown` or `-1` that stands in for every missing foreign key. That last case is worth separating out, because the fix is different: nulls and sentinels are often not join-worthy at all, and filtering them before the join removes the skew instead of managing it.
**Cheapest fix first.** If the other side of the join fits comfortably in executor memory, broadcast it — the shuffle disappears and the skew disappears with it. This is the fix to try first because it is one hint and no restructuring.
**Then AQE.** With `spark.sql.adaptive.enabled` and `spark.sql.adaptive.skewJoin.enabled`, Spark detects an outsized partition at runtime and splits it across several tasks, replicating the matching side. It handles the common cases without a code change. It does not help every case — it works on sort-merge joins, and if the skew is in an aggregation rather than a join it will not fire.
**Salting last.** Add a random suffix to the skewed key on the large side, explode the small side across the same suffix range, join on the salted key, then drop the salt and aggregate. It always works and it is the most invasive: the code gets harder to read and the small side gets multiplied.
**Verify.** Rerun and check the same number you diagnosed with — max task duration, not total runtime. Going from a 25-minute straggler to a 3-minute maximum is the result; a wall-clock number on its own does not tell you whether you fixed skew or just got a quieter cluster.
The formulations
1 · Broadcast the small sideship
fact.join(broadcast(dim), 'key')
No shuffle, so no skew. Try this before anything else.
Splits the outsized partition at runtime. Sort-merge joins only.
3 · Salt the keyworks
left.withColumn('k', concat('key', lit('_'), (rand()*10).cast('int')))
right.join(explode(range(10))) # then join on k, drop salt
Always works, most invasive. Last resort by design.
Add executorsavoid
--num-executors 200 --executor-memory 64g
One task still holds one key. More cores just means more idle ones.
The answer most people give
"The job was slow so I increased the number of shuffle partitions." Repartitioning does not split one key — every row with that key still hashes to the same partition. More partitions makes every *other* task smaller and leaves the straggler exactly as it was.
They’ll ask next
The skewed key turns out to be NULL. Does salting still apply?
EvergreenDriver/executor modelUDFs vs native vs pandas UDFs
Your job fails immediately with "Task not serializable". What causes it, and how do you fix it?
Why they ask this
It is the one Spark error that has nothing to do with data or scale, and the fix follows directly from understanding what crosses from the driver to an executor.
Say this
Your closure captured an object that cannot be serialized — usually a connection, a client or the enclosing class. Create it inside the executor with mapPartitions, or broadcast the serializable data it needed.
The reasoning
Every function you hand to a transformation is shipped to the executors, and shipping means serializing. The function comes with everything it references — and a reference to any field of the enclosing object drags **the whole object** along, which is why touching one string field of a class can fail on a database handle three fields away.
The usual culprits are things that are inherently local: an open connection, an HTTP client, a logger, a file handle. None of them can be meaningfully sent to another machine, and Spark tells you so before running a single task, which is at least a fast failure.
Three fixes, in the order they apply. **Create it on the executor** — `mapPartitions` opens the connection once per partition inside the worker, which is both the correct fix and the efficient one. **Broadcast the data** if what you needed was a lookup table rather than a live object. **Copy the field to a local variable** before the closure when the only problem is that a method reference is pulling in `self`.
The wrong instinct is to make the class serializable so the error goes away. That either fails at runtime on the non-serializable field anyway, or it succeeds and quietly ships a large object to every task, which is a performance problem where you previously had a clear error.
The formulations
Open it inside the executorship
def go(rows):
conn = connect() # created on the executor
for r in rows: yield use(conn, r)
rdd.mapPartitions(go)
Correct and efficient — one connection per partition, not per row.
When what the closure needed was data, not a live object.
Copy the field out firstworks
threshold = self.threshold # plain local
df.filter(col('amount') > threshold)
Stops the closure capturing `self` and everything hanging off it.
Make the class serializableavoid
class Job(Serializable): ...
Either fails anyway on the real offender, or ships a fat object to every task.
The answer most people give
"Switch to the Kryo serializer." Kryo changes how serializable things are encoded; it does not make a live socket serializable. It is a throughput setting, not a fix for this error.
They’ll ask next
You moved the connection into mapPartitions and now the database is refusing connections. What went wrong?