What actually happens between your code and the cluster. Driver and executors, lazy evaluation, why a transformation is free until an action forces it, and what makes a transformation wide.
What each process is responsible for, how work is divided, and which failures each one produces. The vocabulary the rest of the subject is written in.
Lazy evaluation & Catalyst
5
Why nothing runs until an action, what the optimizer is allowed to rearrange, and how that changes what your code means.
Narrow, wide & the stage boundary
5
What makes a transformation wide, why that is the same question as where a stage ends, and what crosses the network when it does.
RDD, DataFrame & Dataset
5
Three APIs, one engine underneath — and one of them the optimizer cannot see into. When dropping to an RDD is right, and how often that is.
Evergreen · asked verbatim
8
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 / 28
Driver/executor modelMemory management & OOM
A Spark job is submitted with spark-submit. Name what runs on the driver and what runs on the executors, and give one failure that can only come from each.
Why they ask this
It is the opening question of most Spark interviews and it establishes whether the candidate has a mental model or a vocabulary.
Say this
The driver builds the plan, schedules stages, tracks task state and collects results; executors run tasks and hold cached data. A driver OOM comes from collect() or an oversized broadcast; an executor OOM comes from a skewed partition.
The reasoning
The driver hosts the SparkContext, builds the logical and physical plans, splits the job into stages at shuffle boundaries, schedules tasks onto executors, tracks which succeeded, and receives whatever an action returns. It is a single process and it is a single point of failure — if it dies the application dies, and any cached data on the executors goes with it.
Executors are JVM processes that do the work. Each runs tasks in slots — one task per core — holds cached partitions in its storage memory, and writes shuffle files to local disk. They are horizontally scalable and individually replaceable: losing one costs you the recomputation of whatever it held.
The failure modes divide cleanly and naming them is what shows understanding. Driver OOM comes from pulling data to it — collect() on a large DataFrame, toPandas(), or a broadcast whose build side turned out bigger than the estimate. Executor OOM comes from a single partition not fitting in a task's memory, which is almost always skew. If someone reports 'Spark ran out of memory', the first question is which process, because the two have nothing to do with each other.
One process, one point of failure. Everything you return to your program passes through it.
Executorsship
run tasks (one per core slot),
hold cached partitions, write shuffle files
Where the work and the data live. Replaceable — losing one costs recomputation, not the job.
Cluster manager (YARN / K8s / standalone)works
allocates containers to the application;
knows nothing about stages or tasks
Only hands out resources. Confusing it with the driver's scheduling is a common slip.
The answer most people give
"The driver distributes the data to the executors." It distributes *tasks*, not data — executors read their own partitions directly from storage. The only data that flows through the driver is broadcasts out and action results back.
They’ll ask next
Your job fails with an OOM. What is the first thing you check to decide which process ran out?
Driver/executor modelPartitioning, repartition vs coalesce
Define job, stage and task in terms of what creates each one, and say how many tasks a stage reading 12 Parquet files with 200 shuffle partitions will have.
Why they ask this
These three words are used loosely in every Spark conversation, and the Spark UI is unreadable until you can attach each to the thing that produces it.
Say this
An action creates a job; a shuffle boundary splits it into stages; a partition creates a task. The scan stage has one task per input split — about 12 — and the post-shuffle stage has 200.
The reasoning
A job is what an action creates. count(), collect(), write() — each triggers one job, which is why a script with four writes shows four jobs in the UI even though you wrote one pipeline.
A stage is a span of work with no shuffle in it. The scheduler cuts the plan at every Exchange, because a stage's tasks must be able to run independently and a shuffle requires all of the previous stage's output. That is the mechanical link between the plan and the UI: stages are the segments between Exchange nodes, and the whole-stage codegen markers *(1), *(2) in a plan number them.
A task is one partition of one stage — the unit actually scheduled onto a core. So task count comes from partition count, and partition count has different sources at different points: on a scan it is the number of input splits, which is roughly file count for modestly-sized files; after a shuffle it is spark.sql.shuffle.partitions, or whatever AQE coalesced it to. Twelve files and 200 shuffle partitions gives roughly 12 tasks then 200.
The answer most people give
"A task is one executor's share of the work." An executor with four cores runs four tasks at once and hundreds over a stage. Tasks map to partitions, not to executors, which is why 200 partitions on a 10-core cluster means 20 waves.
They’ll ask next
A stage shows 200 tasks and 197 finish in seconds. What are you looking at?
Narrow versus wide is the most fundamental distinction in Spark, and the plan makes it visible rather than definitional.
Say this
Each output row depends on exactly one input row, so every task can compute its partition without seeing any other. No data has to move, so there is no Exchange and the whole chain collapses into one stage.
The reasoning
The test is dependency, not operation name. A transformation is narrow when each output partition depends on exactly one input partition — filter drops rows within a partition, withColumn computes from the row in front of it, select projects. None of them needs to know about a row on another executor, so all three fuse into a single stage.
You can read that off the plan: there is no Exchange, and the codegen markers show one group, meaning Spark compiled the whole chain into a single generated function operating on a row at a time. That fusion is why chaining twenty narrow transformations costs almost nothing beyond the one you needed.
Wide transformations are the ones where an output partition needs input from many partitions — groupBy, join on a non-broadcast side, distinct, orderBy, window, repartition. Each forces a shuffle, and therefore a stage boundary. The practical form of this knowledge is that you can look at a chain of PySpark and count the stages before running it, which is exactly what a plan-reading question asks you to do.
What it actually returns 0 shuffles, run on Spark 4.2
"withColumn is wide because it changes the schema." Schema changes cost nothing at runtime — the distinction is about data movement, and computing a new column from the row in front of you moves nothing.
They’ll ask next
Is a filter on a partition column narrow? Is it even a filter by the time it runs?
withColumn, then groupBy, then withColumn again. Exactly where does the stage boundary fall, and which of the two withColumns is in the same stage as the scan?
It forces the candidate to locate the boundary in a specific chain rather than recite that shuffles cause stages.
Say this
At the Exchange the groupBy needs. The first withColumn is in the scan stage; the second is above the shuffle, in the stage that runs after it.
The reasoning
Read it bottom-up, which is how a physical plan is written. The scan, the first withColumn and the partial aggregate are all below the Exchange — one stage, fused into one generated function. The Exchange is the boundary. The final aggregate and the second withColumn are above it, in the next stage.
That placement has a real consequence: work below the shuffle happens on the pre-aggregation row count, and work above it happens on the aggregated row count. Here that is 2,000 rows versus 3. Expensive per-row work is therefore much cheaper above a shuffle than below it, and moving a computation across that line is a legitimate optimisation.
The partial-then-final aggregation is worth naming while it is on screen. Spark does not shuffle raw rows to group them — it aggregates within each partition first, shuffles the partial results, and combines. That is why a sum over billions of rows shuffles almost nothing, and why an aggregation with high cardinality keys is far more expensive than one with three regions.
What it actually returns 1 shuffle, run on Spark 4.2
"Each transformation is a stage." Narrow transformations fuse — a chain of ten of them is one stage and often one generated function. Stages are created by shuffles, not by method calls.
They’ll ask next
You have an expensive UDF to apply. Above or below the shuffle, and why?
df = spark.table("orders")
for i in range(5):
df = df.withColumn(f"x{i}", F.col("amount") + i)
out = df.select("order_id", "x0", "x4")
The code — predict the output before reading on
df = spark.table("orders")
for i in range(5):
df = df.withColumn(f"x{i}", F.col("amount") + i)
out = df.select("order_id", "x0", "x4")
Why they ask this
It demonstrates lazy evaluation concretely rather than definitionally, and it sets up the trap that this pattern is free — which it is not, for a different reason.
Say this
One Project computing only the columns the select keeps — the other three are never computed. In pandas each line would have materialised a full column immediately.
The reasoning
Nothing executed while the loop ran. Each withColumn added a node to a logical plan, Catalyst collapsed the chain, and the projection pruning pass removed the expressions whose outputs the select does not keep. The plan shows a single Project with the surviving expressions in it.
That is the practical face of laziness: you can build a plan across many lines and even many functions, and pay only for what the final action needs. In pandas the same loop allocates five new columns of real memory immediately, whether or not you use them.
The trap worth knowing is that laziness has a cost of its own. Long chains build large plan trees, and Catalyst's analysis is not free — thousands of iterative withColumn calls in a loop is a well-known way to spend minutes in *planning* before a single task runs. The fix is a single select with a list of expressions rather than a loop of withColumn, which produces the same plan without building and rewriting it a thousand times.
What it actually returns 0 shuffles, run on Spark 4.2
Five columns added, two kept, three never computed.
"Each withColumn creates a new DataFrame, so this is expensive." It creates a new plan node, which is a small object. No data is touched, and unused expressions do not survive optimisation at all.
They’ll ask next
Someone runs this loop 2,000 times and the job takes four minutes before any task starts. What is happening?
It tests whether the candidate knows what Catalyst does for them, which is the prerequisite for knowing what it cannot.
Say this
No. Both are combined and pushed into the scan, so they appear in PushedFilters and Parquet skips row groups using them. Writing them in either order produces the same plan.
The reasoning
Catalyst combines adjacent filters and pushes the result as far toward the source as it can. Both predicates end up in the scan's PushedFilters, which means the Parquet reader uses row-group statistics to skip data that cannot match — the rows are never decoded.
So micro-optimising filter order is wasted effort for simple predicates, and that is worth knowing because a lot of hand-tuning goes into it. What is *not* wasted is knowing where the optimizer stops: a Python UDF is opaque and cannot be pushed; a predicate on the null-producing side of an outer join cannot be moved below the join without changing the result; and a filter on a column produced by a window cannot move below the window at all.
The other half of the answer is which predicates Parquet can actually use. Equality and range comparisons on primitive columns work well. A filter needing string manipulation, a cast, or a function over the column may be pushed as a DataFilter but not usable as a PushedFilter — the plan distinguishes them, and reading both lines is how you tell whether pushdown really happened.
What it actually returns 0 shuffles, run on Spark 4.2
Both predicates in PushedFilters, whatever order they were written in.
"Put the most selective filter first so fewer rows reach the second." Both are evaluated in the scan as one combined predicate, and the reader skips row groups on statistics rather than evaluating them in your order.
They’ll ask next
Which of these two predicates would stop being pushed if you wrapped it in a UDF, and what else would you lose?
Given a grouped aggregation, say which of count(), collect(), cache(), explain(), show(5) and write.parquet() trigger execution — and what each returns to the driver.
out = spark.table("orders").groupBy("region").agg(F.count("*").alias("n"))
The code — predict the output before reading on
out = spark.table("orders").groupBy("region").agg(F.count("*").alias("n"))
Why they ask this
The transformation/action split decides when work happens, and cache() and explain() are the two everyone puts on the wrong side.
Say this
count, collect, show and write are actions. cache() is lazy — it marks the DataFrame and does nothing until the next action. explain() runs the optimizer but no tasks, so it moves no data at all.
The reasoning
An action is anything that needs a result: count returns a number, collect returns every row to the driver, show runs a limited job and prints, and write runs the whole plan and produces files. Each creates a separate job, which is why the Spark UI shows more jobs than you have pipelines.
cache() is the one people get wrong. It is a *marker* — it registers the intent to persist and returns immediately. Nothing is stored until an action runs the plan, and even then only the partitions that action touched. df.cache() followed by nothing has done precisely nothing, and df.cache(); df.count() is the idiom because the count is what actually materialises it.
explain() is the other one: it runs analysis and optimisation and prints the resulting plan, without scheduling a single task. That is what makes it safe to call on anything, and it is the reason plan reading is a cheap skill to practise — you can inspect what a job would do without paying for it. The distinction to state clearly is between planning, which is driver-side and free, and execution, which is the cluster.
What it actually returns 1 shuffle, run on Spark 4.2
This plan was produced by explain(). No task ran to make it.
"cache() materialises the DataFrame." It marks it. The data appears in storage memory only when an action runs, and only for the partitions that action needed — which is why a cached DataFrame can be partially cached.
They’ll ask next
You call df.cache() then df.filter(...).count(). Is the whole DataFrame cached?
An executor is configured with 5 cores and 20 GB. How many tasks run on it at once, and how much memory does one task get?
Why they ask this
Cluster sizing conversations are impossible without this arithmetic, and it is where the difference between memory fractions and memory per task shows up.
Say this
Five tasks concurrently, one per core. They share the executor's execution memory rather than getting a fixed slice each, so a single large partition can take most of it and starve the other four.
The reasoning
Cores are task slots: 5 cores means 5 tasks in flight on that executor. Memory is not divided per task — it is a pool. Of the 20 GB a slice is reserved, and spark.memory.fraction (0.6 by default) of the rest is a unified region shared between execution and storage, borrowed dynamically by whichever tasks need it.
That sharing is what turns skew into an OOM rather than a slowdown. Four tasks holding small partitions and one holding a huge one is not a balanced split of the pool: the large task takes what it can, spills the rest to disk, and if a single group cannot fit even alone it fails outright. Spill is the graceful version; OOM is what happens when spilling cannot save you.
The sizing trade is between few fat executors and many thin ones. Fat executors share one broadcast copy and give large tasks room, and they suffer longer garbage collection pauses and lose more computed work when one dies. Thin executors recover faster and each holds its own copy of every broadcast. Around 4 to 5 cores per executor is the usual recommendation because per-process I/O throughput stops improving much past that — and the answer worth giving is the reasoning, not the number.
The formulations
~5 cores per executor, sized to the largest partitionship
--executor-cores 5 --executor-memory 20g
-> 5 concurrent tasks sharing one memory pool
The common default, because per-process I/O throughput stops improving much past 4-5 cores.
Many single-core executorsworks
--executor-cores 1 --executor-memory 4g
Simple isolation and fast recovery; every executor holds its own copy of every broadcast.
One huge executor per nodeavoid
--executor-cores 32 --executor-memory 200g
Long GC pauses, and losing one node loses an enormous amount of computed work.
The answer most people give
"20 GB divided by 5 cores gives each task 4 GB." Execution memory is a shared pool tasks borrow from dynamically, which is exactly why one skewed partition can starve the four tasks beside it.
They’ll ask next
One task OOMs and the other four are fine. What do you change first?
F.broadcast(dim) is applied to a 200 MB dimension on a cluster of 50 executors. Trace what actually happens, and say how much memory is used in total.
Why they ask this
Broadcast joins are recommended constantly and their memory cost is almost never computed. The arithmetic is what makes the risk visible.
Say this
The driver collects all 200 MB, then ships a copy to each executor — 200 MB on the driver plus 200 MB on every one of the 50, so about 10 GB across the cluster for a 200 MB table.
The reasoning
The build side is collected to the driver first. That is the step people forget, and it is why an oversized broadcast produces a driver OOM rather than an executor one — the driver holds the whole thing before it can distribute anything. Then it is sent to each executor, which builds a hash table and keeps it for the job's duration.
So the total is one copy on the driver plus one per executor, and the deserialised hash table is typically larger than the compressed Parquet the size estimate came from. A dimension that looks like 200 MB on disk can be several times that as a hash map of objects, which is how a broadcast that passed the threshold still exhausts an executor.
The symptoms are distinctive enough to name. spark.sql.broadcastTimeout exceeded means the collect-and-ship did not finish in time, usually because the side is far bigger than assumed. A driver OOM in a job with a broadcast hint is the same cause one step earlier. And if neither fires but every executor is under memory pressure, the broadcast worked and is simply too expensive to hold fifty times.
The formulations
Broadcast a genuinely small dimensionship
F.broadcast(dim) # a few MB, hundreds of thousands of rows
Removes both shuffles from the join. The right answer for a star-schema dimension.
Let AQE convert it at runtimeship
spark.sql.adaptive.enabled = true
-> SMJ becomes a broadcast if the shuffled side is small
The same win, decided from actual shuffled size instead of a hard-coded assumption.
Broadcast a table in the hundreds of MBavoid
F.broadcast(big) # collected to the driver first
The driver holds all of it, then every executor holds a copy. A broadcastTimeout or a driver OOM.
The answer most people give
"Broadcasting sends the table straight from storage to the executors." It goes through the driver, which is exactly why the driver is the process that runs out of memory when the estimate was wrong.
They’ll ask next
Your job fails with broadcastTimeout. What are the two plausible causes and how do you tell them apart?
Lazy evaluation & CatalystUDFs vs native vs pandas UDFs
Name three rewrites Catalyst applies to a DataFrame query without being asked, and two it will not do however obvious they look.
Why they ask this
Knowing the optimizer's boundary is what tells you which optimisations are yours to write. Candidates who think it does everything write nothing.
Say this
It pushes predicates, prunes columns, folds constants and inserts null filters before join keys. It will not move an aggregation through a join, and it cannot see inside a Python UDF at all.
The reasoning
The routine rewrites: predicate pushdown so filters reach the scan, column pruning so only referenced columns are read, constant folding so WHERE 1=1 AND x>5 becomes x>5, null propagation including the isnotnull filters inserted before join keys, and join reordering when statistics exist to reorder on.
The boundaries are where your work is. A Python UDF is a black box: it cannot be pushed, reordered or reasoned about, and its presence disables optimisation around it. An aggregation is not moved through a join in the general case, because that is only valid under conditions the optimizer usually cannot prove — so aggregate-before-join is a rewrite you write yourself. And Catalyst optimises one query at a time: it will not notice two branches reading the same expensive intermediate, which is why cache() exists.
The other limit is statistics. Cost-based decisions — join order, join strategy — are only as good as the estimates, and without ANALYZE TABLE those come from file sizes. Compressed Parquet under-reports in-memory size, so the estimate is systematically low. That is the strongest argument for AQE: it replaces guesses with measurements at each stage boundary.
The formulations
Rely on it for predicates and projectionsship
filter/select anywhere; Catalyst pushes them into the scan
It genuinely does this well, so hand-ordering simple filters is wasted effort.
Write the rewrites it will not doship
aggregate before joining; drop columns before a wide op;
replace UDFs with native expressions
These are yours because the optimizer is either not permitted or not able to make them.
Assume it will fix a UDF-heavy pipelineavoid
df.filter(my_udf(col)).groupBy(...) # opaque to Catalyst
The UDF blocks pushdown and reordering, so the scan reads everything and nothing moves.
The answer most people give
"Catalyst will optimise whatever I write, so only readability matters." Readability matters and the optimizer has a boundary — a UDF, a cross-branch reuse, or an aggregation through a join are all on the far side of it.
They’ll ask next
Which of your pipeline's optimisations would disappear if you replaced one native expression with a UDF?
explain(mode='extended') prints four plans. Name them in order and say which one first knows that a column does not exist.
Why they ask this
The four-plan structure is how Catalyst is organised, and knowing which stage catches which error is genuinely useful when debugging.
Say this
Parsed, analysed, optimised, physical. The analysed plan is the first to resolve names against the catalogue, so a missing column fails there — before optimisation and long before any task.
The reasoning
The parsed logical plan is pure syntax: it knows you wrote a select and a filter and has no idea whether custmer_id exists. Analysis resolves every attribute and function against the catalogue, which is where AnalysisException comes from — a typo, a column made ambiguous by a join, a function that does not exist. It is driver-side and instant.
Optimisation applies the rule-based rewrites to produce the optimised logical plan. Physical planning then turns logical operators into strategies: a logical Join becomes BroadcastHashJoin or SortMergeJoin, a logical Aggregate becomes HashAggregate with an Exchange. That last step is where statistics and configuration enter.
Practically this is why explain() is a fast feedback loop. It runs the whole front half of the compiler without scheduling work, so it catches name errors, shows the join strategy, and tells you whether the filter reached the scan — for the cost of a driver-side call. Printing a plan before running an expensive job is one of the cheapest habits available.
The formulations
explain() before running anything expensiveship
df.explain() # driver-side, schedules no tasks
Catches name errors, shows the join strategy and whether pushdown happened, for free.
explain(mode='formatted') on a large treeworks
numbered node list + per-node detail
Easier to read when the tree is deep; the classic form is what interviewers usually show.
Run it and read the Spark UIworks
SQL tab -> final plan with runtime metrics
The only way to see the AQE final plan and real row counts, and it costs a real run.
The answer most people give
"explain() runs the query and shows what happened." It runs the optimizer, not the query. That is why it costs nothing, and also why it cannot show the AQE final plan or any runtime metric.
They’ll ask next
You get an AnalysisException about an ambiguous column after a join. Which stage produced it, and what is the fix?
Describe what happens on disk and on the network during a shuffle, and say why shuffles make Spark jobs fail rather than merely run slowly.
Why they ask this
Most candidates can say a shuffle moves data. Far fewer can say it writes to local disk first, which is what explains the majority of shuffle failures.
Say this
Each map task partitions its output by the target key, sorts it, and writes shuffle files to local disk; reduce tasks then fetch their slice from every map task. It is a full write-and-refetch, and the fetch is all-to-all.
The reasoning
The write side: every task in the upstream stage computes a partition id per row, buffers rows by destination, spills to local disk when the buffer fills, and writes one shuffle data file plus an index. Those files are on the executor's local disk, not in the distributed store — which is the fact that explains the most confusing shuffle failures.
The read side: each task in the downstream stage fetches its partition's bytes from every upstream task. That is an M x N fetch pattern, so connection count grows with the product of the two stage widths rather than their sum. It is why very high shuffle partition counts hurt even when each partition is tiny.
The failure modes follow directly. FetchFailedException means the shuffle files were unreachable, usually because the executor holding them died or was reclaimed — and since the files were on its local disk they went with it, forcing the whole upstream stage to be recomputed. Disk pressure on executors is shuffle spill. And an executor lost during a long shuffle can cascade, because the recomputation shuffles again. The external shuffle service exists precisely so those files outlive the executor that wrote them.
The formulations
Reduce what is shuffledship
filter and aggregate before the wide op;
select only the columns you need
The fix that always works: the cheapest shuffle is the one carrying less.
Remove the shuffle entirelyship
broadcast the small side; bucket both sides on the join key
Bucketing stores the data pre-partitioned, so even the first shuffle disappears.
Raise shuffle partitions until it stops failingavoid
spark.sql.shuffle.partitions = 4000
More, smaller fetches and an M x N connection count. Treats a symptom and adds overhead.
The answer most people give
"A shuffle sends data directly from one executor to another over the network." It is written to local disk first and fetched afterwards. That indirection is why losing an executor loses its shuffle output and triggers recomputation.
They’ll ask next
You see FetchFailedException in the logs. What happened, and what does Spark do next?
Partitioning, repartition vs coalesceWide vs narrow transformations
Both change the partition count. Name the two differences that matter, and say which one can silently reduce the parallelism of the work before it.
Why they ask this
The pair is asked in almost every Spark interview, and the second difference — that coalesce propagates upstream — is what separates a real answer from a recited one.
Say this
repartition shuffles and can raise or lower the count evenly; coalesce merges without a shuffle and only lowers it. And coalesce propagates up the plan, so coalesce(1) before a wide operation makes that whole stage single-threaded.
The reasoning
repartition performs a full shuffle, so it can raise or lower the count and produces even partitions — including a hash-partitioned form when given columns. coalesce avoids the shuffle by merging existing partitions into fewer, which is much cheaper and can leave the result uneven, because it combines whatever was there.
The second difference is the dangerous one. Because coalesce introduces no stage boundary, the reduced parallelism applies to the whole stage it sits in — it propagates upstream. df.map(expensive).coalesce(1).write() does not run the expensive work in parallel and then merge; it runs the entire stage with one task. If the goal is one output file after expensive work, repartition(1) is correct despite the shuffle, because the shuffle creates the boundary that keeps the upstream parallel.
So: coalesce to reduce output files after cheap work; repartition when you need evenness, an increase, a specific distribution, or a stage boundary protecting upstream parallelism. On modern Spark, AQE's partition coalescing handles the common post-shuffle case automatically, which removes most of the reasons people reached for either.
The formulations
coalesce after cheap work, to cut output filesship
df.filter(...).coalesce(8).write.parquet(...)
No shuffle, fewer files. Safe when the upstream work is not the expensive part.
repartition when parallelism must be preservedship
The shuffle creates a stage boundary, so the expensive stage stays parallel.
coalesce(1) before expensive workavoid
df.coalesce(1).map(expensive).write.parquet(...)
Propagates upstream: the whole stage runs single-threaded, and nothing warns you.
The answer most people give
"coalesce is just a cheaper repartition." It is cheaper and it is not equivalent — it only reduces, it can leave partitions uneven, and it silently reduces the parallelism of everything in its stage.
They’ll ask next
You want exactly one output file after an expensive transformation. Which do you use, and why?
Joins are the standard example of a wide transformation. Name two cases where a join causes no shuffle at all.
Why they ask this
It tests whether the candidate holds the dependency definition or a memorised list of wide operations.
Say this
A broadcast join, where the small side is shipped so each partition joins in place; and a join on the bucketing column of two tables bucketed identically, where the data is already co-partitioned on disk.
The reasoning
The definition is about dependency, not about the operation. A join is wide when matching keys have to be brought together, and it is not wide when they already are — or when one side is available everywhere.
Broadcast is the everywhere case. The build side is collected and shipped to every executor, so each partition of the large side joins without moving. The plan shows BroadcastHashJoin with no Exchange, which is why a six-dimension star-schema query can still have exactly one shuffle.
Bucketing is the already-together case, and it is the more interesting answer. If both tables are written bucketed by the join key into the same number of buckets, matching keys are already in corresponding files and Spark can sort-merge join with no Exchange on either side. That is the durable version of the repartition trick: you pay the shuffle once at write time and every later join on that key is free. The cost is that bucketing constrains the write path and fixes the bucket count, which is why it is worth it for a key joined the same way many times a day and not otherwise.
The formulations
Broadcast the small sideship
BroadcastHashJoin, no Exchange on either side
The common case, and automatic when the estimate falls under the threshold.
Bucket both tables on the join keyship
df.write.bucketBy(200, 'customer_id').saveAsTable(...)
-> SMJ with no Exchange
Pays the shuffle once at write time. Worth it for a key joined on many times a day.
Repartition both sides before the joinavoid
a.repartition('k').join(b.repartition('k'), 'k')
Two shuffles to avoid two shuffles. The join would have done exactly this itself.
The answer most people give
"Joins are always wide, that is the definition." Wide means an output partition depends on many input partitions. A broadcast join makes each output partition depend on exactly one, which is the definition of narrow.
They’ll ask next
Both tables are bucketed on the key but into different numbers of buckets. What happens?
RDD vs DataFrame vs DatasetUDFs vs native vs pandas UDFs
The same aggregation written with RDDs and with DataFrames — the DataFrame version is several times faster from Python. Name the three reasons.
Why they ask this
It is the standard API question, and the three reasons are separable: candidates who name only the optimizer have a third of the answer.
Say this
Catalyst can optimise a DataFrame and cannot see inside an RDD lambda; Tungsten stores rows as compact binary rather than JVM objects; and from Python, DataFrame work stays in the JVM while RDD lambdas serialise every row into Python.
The reasoning
Catalyst is the reason people give. A DataFrame expression is declarative, so the optimizer can push filters, prune columns, reorder joins and choose strategies. An RDD map is an opaque function: Spark runs it and can reason about nothing.
Tungsten is the second, and it is about representation. DataFrame rows live in a compact binary format Spark manages directly, so there is no per-object JVM overhead, garbage collection pressure is far lower, and whole-stage code generation can compile a chain of operators into one tight loop over that memory. RDDs of Java or Python objects get none of it.
The third is specific to PySpark and usually the largest. DataFrame operations are descriptions executed entirely in the JVM — your Python process builds a plan and waits. An RDD lambda has to run in Python, so every row is serialised out of the JVM, processed, and serialised back. That is why the gap between the APIs is far wider in PySpark than in Scala, and why 'drop to an RDD for control' is much more expensive advice in Python.
The formulations
DataFrame API for essentially everythingship
df.groupBy('k').agg(F.sum('v'))
Optimised, Tungsten-backed, and stays in the JVM from Python. The default, with few exceptions.
RDD for unstructured input or a custom partitionerworks
Legitimate when there is no schema to have, or you need a partitioner SQL cannot express.
RDD map for row-level logic in PySparkavoid
df.rdd.map(lambda r: ...) # every row crosses into Python
Loses Catalyst, loses Tungsten, and pays serialisation per row. The most expensive way to write it.
The answer most people give
"Datasets are fastest because they are typed." Typed Datasets are a Scala and Java feature, and their lambdas are opaque to Catalyst too — a typed filter with a closure can be slower than the DataFrame equivalent. PySpark has no Dataset API at all.
They’ll ask next
Name a case where you would still drop to the RDD API in PySpark today.
RDD vs DataFrame vs DatasetLazy evaluation & Catalyst
Scala has RDD, DataFrame and Dataset. PySpark has two of the three. Which is missing, why, and what do you lose?
Why they ask this
A small factual question that reveals whether the candidate has worked in both languages or read a Scala-oriented book and assumed it transferred.
Say this
Dataset. It needs compile-time types and an encoder derived from them, and Python has neither — a PySpark DataFrame is exactly Dataset[Row]. What you lose is compile-time schema checking, not performance.
The reasoning
A Dataset is a typed collection: Dataset[Order] knows its element type at compile time, so the compiler rejects a reference to a field the case class lacks. That needs static types and a generated encoder, neither of which Python has. In Scala, DataFrame is literally a type alias for Dataset[Row] — the untyped end of one API.
So in PySpark, errors Scala catches at compile time surface at analysis time instead: an AnalysisException when the plan is resolved, before any task runs. That is a real difference in feedback speed and a small one in practice, because analysis is instant and happens on the driver.
The thing worth knowing is that typed Datasets are not automatically faster in Scala either. A typed lambda in ds.filter(o => o.amount > 100) is a closure Catalyst cannot see into, so it loses pushdown exactly as a UDF does — the expression form optimises better. Type safety and optimisability pull in opposite directions, which is not what most people expect.
The whole structured API in Python. Errors surface at analysis rather than compile time.
Scala Dataset with column expressionsship
ds.filter($"amount" > 100)
Typed container, optimisable predicate. The combination that actually performs.
Scala Dataset with typed lambdasworks
ds.filter(o => o.amount > 100) // opaque closure
Compile-time safety at the cost of pushdown — the closure is as opaque as a UDF.
The answer most people give
"PySpark DataFrames are untyped, so they are slower." They carry a schema and run through exactly the same Catalyst and Tungsten machinery. What is missing is compile-time checking, not runtime performance.
They’ll ask next
Where does a PySpark schema error surface, and how early can you make it surface?
RDD vs DataFrame vs DatasetUDFs vs native vs pandas UDFsPartitioning, repartition vs coalesce
Give a case where df.rdd is the correct answer in PySpark, and say what it costs you.
Why they ask this
The honest answer is 'rarely', and the interviewer wants to see you name the exception without reaching for it by default.
Say this
A custom partitioner the SQL API cannot express, and parsing genuinely unstructured input where there is no schema yet. It costs Catalyst, Tungsten, and in Python a serialisation round trip per row.
The reasoning
The clearest case is a custom partitioner. The DataFrame API offers hash and range partitioning; if you need rows grouped by something neither expresses — a locality rule, a hash chosen to co-locate with an external system — rdd.partitionBy is the only route. Even then the usual answer is to compute a partition-key column and hash-partition on that instead.
The second is genuinely unstructured input: a log format needing stateful parsing across lines, or a binary format with no reader. mapPartitions gives you a whole partition at a time to parse and then convert to a DataFrame — and note the shape of that advice, which is to drop down for the parse and come straight back up.
The cost is everything the structured API buys: no pushdown, no pruning, no whole-stage codegen, objects instead of Tungsten rows, and from PySpark a serialisation round trip per row. mapPartitions at least amortises Python startup across a partition rather than a row, which is why it is preferred over map whenever you are down there. If someone reaches for df.rdd.map to apply row-level logic, the answer is nearly always a native expression or a pandas UDF.
The formulations
Compute a key column and hash-partition on itship
df.withColumn('pk', expr).repartition(200, 'pk')
Expresses most custom-partitioning needs while staying inside the optimised API.
mapPartitions for an unstructured parseworks
rdd.mapPartitions(parse_batch) # then back to a DataFrame
Legitimate when there is no schema yet, and it amortises Python startup across a partition.
df.rdd.map for row-level business logicavoid
df.rdd.map(lambda r: (r.k, r.v * 2)).toDF()
Loses the optimizer and Tungsten, and serialises every row into Python for something a column expression does.
The answer most people give
"Use RDDs when you need fine-grained control." Fine-grained control over row logic is what a native expression or a pandas UDF gives you at a fraction of the cost. The genuine reasons are partitioning and unstructured input, and both are rare.
They’ll ask next
You need a pandas library applied per group. RDD, UDF, or something else?
UDFs vs native vs pandas UDFsRDD vs DataFrame vs Dataset
A scalar Python UDF, a pandas UDF, and a native column expression. Rank them, and say what changes between each step.
Why they ask this
UDF choice is one of the highest-leverage decisions in PySpark, and the difference between the three is mechanical rather than a matter of taste.
Say this
Native is fastest and optimisable; a pandas UDF removes the per-row serialisation by moving Arrow batches; a scalar Python UDF is slowest and blocks pushdown. Both UDF forms stay opaque to Catalyst.
The reasoning
A native expression is compiled into the generated JVM code, can be pushed into the scan, and never leaves the JVM. If the logic is expressible with column functions — and when/otherwise, regexp_extract, transform and the higher-order functions cover far more than people try — this is the answer.
A scalar Python UDF is the opposite end: rows are serialised out of the JVM, deserialised into Python objects, the function is called per row, and results go back. On top of that cost the plan shows BatchEvalPython, and the scan can no longer push your predicate — so you also read more data than you needed to.
A pandas UDF sits between them, and the improvement is specifically about transport. Data moves as Arrow batches, the function receives a pandas Series and returns one, so per-row call overhead and most of the serialisation cost disappear — often several times faster. What it does not fix is opacity: Catalyst still cannot see the logic, so pushdown and reordering are still lost. The ordering is native, then pandas UDF, then scalar UDF, and the jump from the last to the middle is usually larger than people expect.
The formulations
Native column expressionship
F.when(F.col('a') > 300, F.col('a')).otherwise(0)
Compiled into the JVM and pushable into the scan. Covers far more logic than most people try.
Arrow batches instead of per-row serialisation. The right answer when Python is genuinely required.
Scalar Python UDFavoid
F.udf(lambda a: ..., 'double')
A per-row round trip and it blocks pushdown, so you read more data and process it slower.
The answer most people give
"pandas UDFs let Catalyst optimise the logic." They fix the transport, not the opacity. The optimizer still cannot see inside, so the filter still will not reach the scan.
They’ll ask next
Your pandas UDF needs the whole group rather than a column. Which variant, and what does it cost?
A stage is 80% complete and one executor is lost — spot instance reclaimed. What does Spark do, and what determines how much work is repeated?
Why they ask this
Fault tolerance is the reason Spark exists, and the answer turns on lineage and on where shuffle files live — both of which candidates state vaguely.
Say this
It reschedules that executor's tasks elsewhere and recomputes their partitions from lineage. How much is repeated depends on whether the lost work was shuffle output another stage still needs, and on whether an external shuffle service was holding it.
The reasoning
Spark tracks the lineage of every partition — the chain of transformations that produces it — so a lost partition is recomputed rather than replicated. The tasks that were running on the dead executor are rescheduled, and the driver marks its results as gone.
The cost depends entirely on what was lost. Tasks merely in flight are cheap: rerun them. Shuffle files the executor had already written are expensive, because a downstream stage needs them and they lived on that executor's local disk — so Spark reports FetchFailedException and re-runs the map tasks that produced them, which can cascade back several stages. Cached partitions are somewhere between: recomputed on next use, and cheap only if the lineage behind them is.
Two things change that arithmetic and are worth naming. An external shuffle service holds shuffle files outside the executor process, so losing an executor no longer loses its shuffle output — which is why it is close to mandatory on spot instances or with dynamic allocation. And checkpointing truncates lineage by writing to reliable storage, which is how you stop a very long chain from being recomputed from the source.
The formulations
External shuffle service, so shuffle output survivesship
spark.shuffle.service.enabled = true
Losing an executor stops meaning losing its shuffle files. Close to mandatory on spot capacity.
Checkpoint to truncate a very long lineageworks
df.checkpoint() # writes to reliable storage, cuts the chain
Bounds recomputation on deep pipelines, at the cost of a real write.
Rely on cache() for fault toleranceavoid
df.cache() # in executor memory, dies with the executor
Cache is a performance feature, not a durability one — the cached blocks go with the process.
The answer most people give
"Spark replicates partitions, so nothing is lost." It recomputes from lineage rather than replicating. Replication is what cache with a _2 storage level does, and it is opt-in precisely because it costs memory.
They’ll ask next
Your job keeps hitting FetchFailedException on spot instances. What do you turn on?
RDD vs DataFrame vs DatasetPredicate & projection pushdown
Reading a large CSV with inferSchema=True takes noticeably longer before any work starts. Why, and what happens with Parquet instead?
Why they ask this
It is a concrete, common cost that reveals whether the candidate understands that a schema has to come from somewhere.
Say this
Inference reads the data to work out the types — an extra pass over the file before your job begins. Parquet carries its schema in the footer, so there is nothing to infer and the read is metadata-only.
The reasoning
CSV has no types. To produce a DataFrame with a schema, Spark has to look at the data — with inferSchema it samples or scans the file, decides each column's type, and only then plans your query. On a large file that is a full extra pass, and it happens on every run.
It is also a correctness risk, which is the part worth raising. Inference is a guess from what it saw: a column of digits that is really an identifier becomes an integer and loses leading zeros; a column that is integral in the sampled rows and decimal later fails or coerces. Supplying an explicit StructType removes both the pass and the guess, and it makes a schema change from upstream fail loudly instead of silently retyping.
Parquet has none of this because the schema is in the file footer. Reading it is a metadata operation, the types are exact, and the same footer carries the column statistics that make predicate pushdown work. That is the practical argument for converting text sources to Parquet at landing and never reading the raw CSV twice.
The formulations
Explicit schema on any text sourceship
spark.read.schema(StructType([...])).csv(path)
No inference pass, exact types, and an upstream change fails loudly instead of retyping silently.
Convert to Parquet once at landingship
read csv with schema -> write parquet -> read parquet from then on
Schema in the footer, statistics for pushdown, and the text file is never parsed twice.
inferSchema=True on a large recurring readavoid
spark.read.option('inferSchema', True).csv(path)
A full extra pass on every run, and the types are a guess that can change with the data.
The answer most people give
"inferSchema is free because Spark is lazy." Inference is eager — it has to read data to produce the schema, and that read happens before your plan is even built.
They’ll ask next
Your CSV gains a column upstream. What happens with an explicit schema, and what happens with inference?
Name the main components of a Spark application and say what each one is responsible for.
Why they ask this
It is the opening question of most Spark screens, and the answer separates a mental model from a memorised list — most candidates name two of the three.
Say this
Driver, cluster manager and executors. The driver plans and schedules, the cluster manager hands out containers, and the executors run the tasks and hold the cached data.
The reasoning
The driver hosts the SparkSession, builds the logical and physical plan, cuts the plan into stages at shuffle boundaries, schedules tasks onto executors, tracks which succeeded, and receives whatever an action returns. It is one process, so it is one point of failure.
The cluster manager — YARN, Kubernetes or standalone — allocates the containers the executors run in. It is the component candidates drop, and dropping it is where the common confusion starts: the cluster manager decides how much hardware the application gets, and it knows nothing at all about stages, tasks or shuffles. It cannot schedule your work and it cannot tell you why a stage is slow.
Executors are the JVM processes that do the work. Each runs tasks in slots — one task per core — holds cached partitions in storage memory, and writes shuffle files to local disk. They are replaceable: losing one costs the recomputation of whatever it held, not the job.
The reason this ordering matters is that it tells you where to look when something breaks. A resource request that never gets granted is a cluster-manager problem, a plan that schedules badly is a driver problem, and a task that dies is an executor problem — and the logs live in three different places accordingly.
One process, one point of failure. Everything returned to your program passes through it.
Cluster managership
YARN / Kubernetes / standalone:
allocates executor containers — CPU and memory
Hands out hardware only. It has no idea what a stage is.
Executorsship
run tasks (one per core slot),
hold cached partitions, write shuffle files
Where the work and the data live. Individually replaceable.
The answer most people give
"The driver sends the data out to the executors." It sends *tasks*, not data — executors read their own partitions straight from storage. The only data crossing the driver is broadcasts on the way out and action results on the way back.
They’ll ask next
Which of the three would you blame if the application sits in ACCEPTED state and never starts?
EvergreenDriver/executor modelRDD vs DataFrame vs Dataset
What is the difference between SparkContext and SparkSession, and is there any reason to touch the older one today?
Why they ask this
It dates a candidate instantly — and the honest answer is not "never use SparkContext", which is what people who learned from a cheat sheet say.
Say this
SparkContext is the RDD-era entry point; SparkSession has been the unified one since Spark 2.0 and wraps a SparkContext inside it. You reach through to the inner context for the handful of things it still owns — accumulators, broadcast variables and RDD creation.
The reasoning
SparkContext was the original entry point. It creates RDDs, accumulators and broadcast variables, and it is the object that actually holds the connection to the cluster. Every Spark 1.x example starts by constructing one, and older code often also carries a SQLContext or HiveContext alongside it.
Spark 2.0 collapsed those into SparkSession, which is what you build today: DataFrames, Spark SQL, Structured Streaming and the catalog all hang off it. It does not replace SparkContext so much as contain one — `spark.sparkContext` returns it, and that is not a legacy escape hatch, it is the supported way to reach the APIs that never moved.
So the useful version of the answer is: build a SparkSession, and drop to `spark.sparkContext` for accumulators, broadcast variables, `parallelize`, and setting a job group or job description. Saying "SparkContext is deprecated" is wrong — it is not deprecated, it is wrapped.
Correct and supported. These APIs never moved onto the session.
SparkContext() constructed directlyavoid
sc = SparkContext(conf=conf) # then a separate SQLContext
Spark 1.x shape. You end up managing two objects that should be one.
The answer most people give
"SparkContext is deprecated, never use it." It is not deprecated — SparkSession is built on top of one, and `spark.sparkContext` is how you create a broadcast variable. The thing that is obsolete is *constructing* it yourself alongside a SQLContext.
They’ll ask next
You need a broadcast variable inside a mapPartitions call. Which object do you get it from?
EvergreenLazy evaluation & CatalystWide vs narrow transformations
Explain what the DAG is and how Spark uses it. If your script calls three actions, how many DAGs does Spark build?
Why they ask this
The "three actions, three jobs" answer is the cheapest test of whether someone has actually opened the Spark UI, and it explains a whole class of "why did it read the file four times" confusion.
Say this
The driver records transformations as a directed acyclic graph rather than running them. One action triggers one job with its own DAG, so three actions build three — and each one recomputes from the source unless you cached in between.
The reasoning
Every transformation you call adds a node describing an operation and its inputs. Nothing executes: the driver is building a graph. It is directed because data flows one way and acyclic because an operation never feeds back into its own input, which is what lets the scheduler order it at all.
An action forces evaluation. At that moment the driver optimises the graph, cuts it into stages at every shuffle boundary, and turns each stage into one task per partition. So the unit an action creates is a job, and a job has its own DAG.
Three actions therefore give three jobs and three DAGs — and this is the part that surprises people, because each one starts again from the source. A script that calls `count()`, then `show()`, then `write()` on the same DataFrame reads the input three times and reruns every transformation three times. Caching between them is what makes the second and third jobs cheap, and it is the reason the Spark UI showing four jobs when you wrote "one pipeline" is not a bug.
Reads and recomputes three times. The common accidental cost.
Three actions, cached onceship
df = read().filter().join().cache()
df.count() # materialises; the next two reuse it
Still three jobs, but two of them read from cache instead of the source.
One actionship
df.write.parquet(p) # 1 job, no cache needed
Nothing to reuse, so nothing to cache. The cheapest shape when it fits.
The answer most people give
"The DAG is built once for the whole program." It is built per action. That is exactly why a job someone describes as one pipeline shows up as four jobs in the UI, each re-reading the source.
They’ll ask next
Your script has one action but the UI shows two jobs. What else creates one?
EvergreenCaching & persistence levelsDriver/executor model
How does Spark achieve fault tolerance, and at what point does that mechanism stop being enough?
Why they ask this
Everyone can say "lineage". The follow-up — when recomputation becomes the problem rather than the solution — is what the question is actually for.
Say this
Spark records how each partition was derived, so a lost partition is recomputed from its parents rather than replicated. It stops being enough when the lineage gets long or iterative, at which point you checkpoint to cut the chain.
The reasoning
Every DataFrame or RDD knows the operations that produced it. When an executor dies, the driver does not need a replica — it reschedules the lost partitions and replays the transformations that built them. This is why Spark can run on cheap, preemptible hardware where losing a node is routine.
The cost is that recomputation walks backwards through the whole chain. In an iterative job — a loop that unions or joins onto the same DataFrame each pass — the lineage grows every iteration, so a failure late in the loop replays everything from the start, and the plan itself gets slow to build and can eventually blow the driver's stack.
Checkpointing is the cut. It writes the data to reliable storage and **truncates the lineage**: the checkpointed DataFrame's parents are forgotten, so recovery restarts from the checkpoint instead of the source. That is a different purpose from caching, and it is where the two get confused. Cache is a performance hint — it can be evicted, and if it is, lineage silently rebuilds it. A checkpoint is durability: it costs a full write and a second pass, and in exchange the history is genuinely gone.
The practical rule: cache what you reuse, checkpoint what you cannot afford to recompute or whose lineage is growing without bound.
The formulations
Lineage (the default)ship
# nothing to write — Spark already knows the derivation
lost partition -> replay its parents
Free, and correct for the overwhelming majority of jobs.
cache() / persist()works
df.cache() # memory, evictable
Speed, not safety. Evicted under pressure, and lineage rebuilds it.
checkpoint()ship
spark.sparkContext.setCheckpointDir(path)
df.checkpoint() # writes to storage, cuts lineage
For iterative or very long chains. Costs a write and a re-read.
The answer most people give
"Spark is fault tolerant because it replicates data across executors." It does not replicate — that is HDFS. Spark recomputes, which is why a job with an unreadable source file fails outright instead of falling back to a copy.
They’ll ask next
You cached a DataFrame and an executor died. Is the cached data gone, and does the job fail?
What does the Catalyst optimizer do? Walk through its phases and name the optimizations it applies.
Why they ask this
It is the standard "do you know what is under the API" question, and the phases are the vocabulary the rest of a plan-reading conversation is conducted in.
Say this
Catalyst turns your DataFrame or SQL into an executable plan through four stages — unresolved, analyzed, optimized, physical — applying rewrite rules along the way and choosing join strategies at the end.
The reasoning
The four phases. **Unresolved logical plan**: the shape of your query, with column and table names not yet checked. **Analyzed**: names resolved against the catalog, types attached — this is where a misspelled column becomes an AnalysisException. **Optimized logical plan**: rule-based rewrites applied. **Physical plan**: one or more executable strategies generated, costed, and one chosen — this is where broadcast versus sort-merge join is decided.
The rules worth being able to name: **predicate pushdown** moves filters as close to the source as possible, and for Parquet or ORC that means the scan itself skips row groups. **Column pruning** reads only the columns the query references. **Constant folding** evaluates constant expressions once — `price * (10/2)` becomes `price * 5`. **Join reordering** and **boolean simplification** rearrange the query shape. **Limit pushdown** stops a scan early.
The distinction to hold onto is that the first three phases are rule-based and the last one is where cost estimates enter. That is why a plan can change without your code changing: statistics changed, so the physical strategy changed. It is also why the optimizer can be wrong — the rules are always safe, the cost estimates are guesses, and a stale statistic that says a table is 8 MB will get it broadcast when it is 8 GB.
The formulations
Predicate pushdownship
df.join(other, 'id').filter(col('country') == 'IN')
# -> filter pushed under the join, into the scan
Look for PushedFilters in the scan node to confirm it happened.
Column pruningship
df.select('customer_id')
# -> ReadSchema shows one column, not the whole table
The reason SELECT * costs money on a columnar format.
Constant foldingship
df.selectExpr('price * (10/2)') # -> price * 5.0
Evaluated once at plan time rather than per row.
The answer most people give
"Catalyst rewrites your query so the way you write it does not matter." The rules are safe rewrites, not a rescue. A Python UDF in a filter blocks pushdown outright, and a stale statistic can steer the physical plan into a broadcast that OOMs the driver.
They’ll ask next
Which of the four phases raises AnalysisException for a column that does not exist?
What is whole-stage code generation, why is it faster, and when does Spark fall back to the old model?
Why they ask this
The first two thirds are recall. The fallback is the part that shows someone has read a plan closely enough to notice when the codegen markers stop appearing.
Say this
Spark compiles a whole chain of operators into a single generated Java method at runtime, replacing the row-at-a-time iterator model. It falls back when the generated code would be too large or an operator does not support codegen.
The reasoning
Without codegen, each operator is an iterator that pulls a row from the one below through a virtual call, boxing and unboxing values as it goes. That is a lot of overhead per row for work that is often a single comparison.
Whole-stage codegen collapses the operators in a stage into one generated function — a tight loop over raw memory, with no virtual dispatch and far less object churn, which also keeps the CPU cache useful. You can see it in a physical plan: operators inside a codegen stage are marked with `*` and a stage number, like `*(2) Filter`.
It does not always apply, and the absence of those markers is a real diagnostic signal. Spark falls back when an operator does not implement codegen support, and when the generated code would exceed its size limits — controlled by `spark.sql.codegen.maxFields` (default 100) for very wide schemas, and by the JVM's method size limit, which Spark guards with `spark.sql.codegen.hugeMethodLimit`. A query over a table with hundreds of columns, or one with an enormous chain of expressions, can quietly lose codegen and get slower for no visible reason.
The `*(n)` prefix means these operators compiled into one generated method.
Codegen absentworks
HashAggregate(...) <- no star
+- Project [...]
Correct, just slower. Worth noticing on a very wide schema.
The answer most people give
"Whole-stage codegen means Spark compiles your job ahead of time." It generates and compiles Java at runtime, per query, per stage — which is also why the first run of a query pays a small compilation cost the later ones do not.
They’ll ask next
Your plan lost its codegen markers after someone added fifty columns to the table. What would you check?
What is the difference between Spark and MapReduce, and why did Spark replace it for most workloads?
Why they ask this
It is the oldest question in the subject and it is still asked, because the answer people give reveals whether they understand *why* the engine is shaped the way it is.
Say this
MapReduce writes to disk between every map and reduce; Spark keeps intermediate results in memory and plans a whole DAG rather than a fixed two-phase job. That, plus one runtime for SQL, streaming and ML, is the whole story.
The reasoning
MapReduce runs one fixed shape: map, shuffle, reduce — and it materialises the output of every phase to HDFS. A five-step pipeline is therefore five separate jobs with four full round trips to disk, and an iterative algorithm re-reads its input on every pass.
Spark plans an arbitrary DAG. Intermediate results stay in executor memory unless they spill, stages are cut only where a shuffle genuinely requires it, and consecutive narrow operations fuse into a single pass over the data. For an iterative workload — the case Spark was originally built for — that is the difference between re-reading the input each iteration and keeping it resident.
The second half is scope. MapReduce is a batch execution engine; anything else was a separate system on top (Hive for SQL, Storm for streaming, Mahout for ML). Spark ships one engine with SQL, structured streaming, and ML on the same runtime and the same optimizer, which matters more in practice than the raw speed number.
The claim to be careful with is "100× faster". That figure comes from a specific in-memory iterative benchmark. On a single-pass job that reads a terabyte, filters it and writes it back, both engines are bounded by I/O and the gap is far smaller. Say where the advantage comes from and the number takes care of itself.
# a benchmark number from iterative in-memory workloads
True in the case it was measured on; misleading as a general claim.
The answer most people give
"Spark is faster because it runs in memory and MapReduce runs on disk." Spark spills to disk constantly — every shuffle writes to local disk. The difference is that it does not *have* to materialise between every stage, not that it never touches disk.
They’ll ask next
On a single-pass job that reads a terabyte and writes it back out, how much faster would you expect Spark to be?
Explain tumbling, sliding and session windows, and say what a watermark does that the window itself does not.
Why they ask this
Streaming questions collapse into this pair, and the split matters: the window decides which events belong together, the watermark decides when that group is finished.
Say this
Tumbling windows are fixed and non-overlapping, sliding windows are fixed and overlapping, session windows are defined by an inactivity gap. The watermark is separate — it is the rule that decides a window can finally be emitted.
The reasoning
**Tumbling**: fixed length, no overlap. Five-minute tumbling gives 10:00–10:05, 10:05–10:10, and every event lands in exactly one. **Sliding**: fixed length, emitted more often than its length — a five-minute window every minute, so an event belongs to five windows at once. More granular and roughly five times the state and compute. **Session**: no fixed length at all; a window closes after a gap of inactivity, which is what you want for user sessions where the natural boundary is behavioural rather than clock-based.
The watermark answers a different question. Windows group by event time, but events arrive late, so at any moment a window might still receive more data. The watermark is a declared bound — "I will not wait for anything more than ten minutes late" — expressed as the maximum event time seen so far minus that threshold. When the watermark passes a window's end, the window is closed, its result emitted, and its state dropped.
This is why the two are inseparable in practice. **Without a watermark, a windowed aggregation over an unbounded stream has no reason to ever finalise a window, so state grows without limit** — the engine cannot know that no more 10:00 events are coming. And the threshold is a real trade: a long watermark waits for stragglers and delays every result; a short one emits promptly and drops the late arrivals, which then have to be handled somewhere else, as a side output or a correction job.
The formulations
Tumblingship
window(col('event_time'), '5 minutes')
One window per event. The default choice for periodic aggregates.
Each event lands in five windows. Five times the state — use when you need the smoothing.
Sessionship
session_window(col('event_time'), '30 minutes')
Boundary comes from the data, not the clock. The right shape for user sessions.
No watermark on a streamavoid
df.groupBy(window(...)).count() # no withWatermark
Windows never finalise, state grows without bound.
The answer most people give
"The watermark drops late data." It sets the point after which a window stops waiting. Data later than that is dropped *from that window* — but if you route it to a side output or a correction path it is not lost, and treating the watermark as a filter is how teams end up silently under-counting.
They’ll ask next
Your watermark is ten minutes and results feel too slow. What breaks if you cut it to one?