Apache Spark Interview Questions

Reviewed by Mark Dickie · Last updated

Apache Spark is a distributed, in-memory data processing engine that runs batch, streaming, SQL, and machine-learning workloads across a cluster. For a Spark interview, you need to understand the difference between RDDs, DataFrames, and Datasets, how lazy evaluation builds a DAG of transformations, and how partitioning and shuffles control performance. Interviewers will also ask about cluster deployment modes, memory management, and when to use broadcast joins or caching to avoid repeated computation across stages. The table below maps the concepts you are most likely to be tested on and what each one probes in a live interview setting.

ConceptWhat it testsKey points to recall
RDDLow-level API knowledgeImmutable, partitioned, fault-tolerant via lineage; use when you need fine-grained control over every record
DataFrameStructured data processingOptimized by Catalyst, schema-aware, supports SQL expressions directly
DatasetType-safe structured APICombines DataFrame optimization with compile-time type checking (JVM/Scala only)
Lazy evaluationDAG understandingTransformations build a plan; only actions trigger execution
ShufflePerformance awarenessTriggered by wide dependencies; carries network and disk I/O cost across the cluster
Broadcast joinTuning know-howSmall table sent to every executor so the large side is never shuffled

What does a Spark interview typically cover?

Most Spark interviews group into three areas: core engine mechanics (RDDs, DAG, partitioning), structured APIs (DataFrames, Spark SQL, Datasets), and operational concerns (cluster managers, memory, tuning). You should be able to explain why Spark is faster than MapReduce (in-memory computation, DAG-based execution instead of write-to-disk between steps) and trace how a transformation pipeline turns into stages and tasks on the Spark UI.

  1. Explain the difference between transformations and actions, and give examples of each.
  2. Describe how Spark builds a DAG from your code and what triggers physical execution.
  3. Compare RDD, DataFrame, and Dataset APIs and explain when you would pick one over the others.
  4. Walk through how a shuffle works and what causes wide vs narrow dependencies.
  5. Discuss strategies to reduce shuffle: repartitioning, broadcast variables, partitioning by key.
  6. Explain broadcast variables and accumulators and how they differ from regular driver-side variables.
  7. Describe the roles of driver, executor, and cluster manager, and the difference between client and cluster deploy mode.
  8. Talk through caching and persistence levels and when not to cache.
  9. Cover Structured Streaming semantics: event-time processing, watermarking, and output modes.

How should I approach Spark performance questions?

Performance questions usually start with a slow-job scenario. You want to walk the interviewer through diagnosis first: check the Spark UI for stage times and shuffle read/write volumes, look at partition sizes for skew, then apply the right fix. Common fixes include increasing parallelism via spark.sql.shuffle.partitions, using broadcast joins for small dimension tables, enabling adaptive query execution (AQE) for dynamic coalescing and skew handling, and co-locating related data through pre-partitioning. Interviewers reward candidates who can connect a symptom in the UI to a concrete configuration change or code rewrite.

Key facts

  • Tarmac has 100 Apache Spark interview questions on this topic, 25 of them on this page, at difficulty 1–5 of 5.
  • Tarmac last reviewed these Apache Spark interview questions on 31 August 2026.

At a glance

Questions25 shown · 100 in the bank
Difficulty1–5 of 5
FormatsFlashcard, Fill in the blank, True / false, Multiple choice, Multiple answer, Code output, Find the bug, Short answer, Ordering

What you'll review

  1. transformations vs actions
  2. catalyst optimizer
  3. partitioning shuffle
  4. rdd vs dataframe vs dataset
  5. lazy evaluation
  6. common pitfalls
  7. executor memory model
  8. data skew

Practice questions

Apache Spark/core-abstractions/transformations-vs-actions

What is the difference between a transformation and an action in Spark?#

Show answer

A transformation (map, filter, select, groupBy, join, ...) describes a new RDD/DataFrame derived from an existing one and is lazy — Spark only records it as a node in a logical plan, without touching any data. An action (collect, count, show, write, take, ...) is what actually forces execution: it triggers Spark to optimize and run the accumulated chain of transformations and produce a real result — either returned to the driver (collect, count) or written to storage (write). Nothing runs on the cluster until an action is called.

Why:

This distinction underlies almost everything else about Spark's performance model: because transformations only build up a plan rather than executing immediately, Spark can see and optimize the entire chain before any of it runs, and can pipeline compatible steps into fewer physical stages. Forgetting that transformations are lazy is a frequent source of confusion for engineers new to Spark, who sometimes expect a .filter() call alone to have already 'done something' before any action has been called.

Apache Spark/execution-model/catalyst-optimizer

The Spark SQL Catalyst optimizer processes a parsed query through several phases. The first phase is called _____, in which unresolved references such as table names and column names are resolved against the session catalog.#

Show answer

The Spark SQL Catalyst optimizer processes a parsed query through several phases. The first phase is called Analysis, in which unresolved references such as table names and column names are resolved against the session catalog.

Why:

Catalyst's pipeline begins with Analysis, where the Analyzer uses the catalog to turn an unresolved logical plan (containing 'UnresolvedRelation' and 'UnresolvedAttribute' nodes) into a resolved logical plan with concrete table and column references.

Apache Spark/execution-model/catalyst-optimizer

Catalyst optimizes the resolved logical plan by applying a series of _____-based transformations such as constant folding, null propagation, and predicate pushdown.#

Show answer

Catalyst optimizes the resolved logical plan by applying a series of rule-based transformations such as constant folding, null propagation, and predicate pushdown.

Why:

Catalyst's logical optimization phase is driven by a set of Rule objects that pattern-match on logical plan nodes and transform them. These rules (e.g., ConstantFolding, PushDownPredicate) are applied repeatedly until a fixed point is reached.

Apache Spark/execution-model/partitioning-shuffle

In Apache Spark, calling map() on an RDD produces a new RDD whose partitions depend on exactly one parent partition each, so no shuffle is required to compute it.#

Options

Show answer

True. map() is a narrow transformation in Spark — each output partition depends on exactly one input partition, so no data needs to move between executors and no shuffle is triggered. Wide transformations like groupByKey or reduceByKey are the ones that force a shuffle.

Why:

map is a narrow transformation: each output partition is derived from a single input partition, so Spark can compute it locally without moving data across executors. Wide transformations such as groupByKey or reduceByKey, by contrast, require a shuffle because an output partition depends on data from many input partitions.

Apache Spark/core-abstractions/rdd-vs-dataframe-vs-dataset

What is the main practical benefit of using a DataFrame instead of a raw RDD for the same computation in Spark?#

Options

Show answer

The main benefit of a DataFrame over a raw RDD is that a DataFrame carries a schema and is built from a declarative API, which lets Spark SQL's Catalyst optimizer build and optimize an execution plan before anything runs — pushing filters down, pruning unread columns, and choosing join strategies. An RDD's transformations are opaque user functions Spark cannot see inside, so it can only schedule and pipeline stages rather than optimize the logic. Both APIs execute distributed across the cluster's executors, and RDDs remain fully supported rather than deprecated.

Why:

An RDD's transformations are arbitrary user functions (a lambda passed to .map(), for instance) — Spark has no way to see inside that lambda, so it can only schedule and pipeline the stages, not optimize the logic itself. A DataFrame, in contrast, is built from a declarative, schema-aware API (select, filter, groupBy, expressed as an internal logical plan), which the Catalyst optimizer can analyze and rewrite before any of it runs — pushing filters down to the data source, pruning columns nobody reads, choosing a join strategy based on estimated sizes, and generating optimized JVM bytecode via whole-stage code generation (Tungsten). That's why equivalent DataFrame code is routinely faster than the RDD version doing the 'same' work by hand. RDDs still run on current Spark versions and are not deprecated (c is false), and both APIs execute distributed across the cluster's executors, not just the driver (b and d are both false) — the driver only coordinates and, for actions like collect(), receives results.

Apache Spark/core-abstractions/lazy-evaluation

Why does Spark defer executing transformations like map and filter until an action like collect() or count() is called?#

Options

Show answer

Spark defers executing transformations until an action is called so it can build the complete logical plan for the whole chain before running any of it. That lets the Catalyst optimizer rewrite the plan as a whole, such as pushing a filter below a join, and lets the physical execution pipeline multiple narrow transformations into fewer stages instead of materializing an intermediate result after every single call. This is a core, active part of how Spark achieves performance rather than a legacy behavior.

Why:

If Spark executed map, then filter, then select as three separate, immediately-run steps, each one would have to fully materialize its output before the next could start — reading and writing an intermediate result to memory/disk at every stage. By instead recording each transformation as a node in a lazy logical plan (a DAG) and only executing anything when an action forces a result, Spark gets to see the entire chain at once: the Catalyst optimizer can then rewrite that whole plan (e.g. pushing a filter below a join so less data flows through the expensive join), and the physical execution can fuse multiple narrow transformations that don't require a shuffle into a single pipelined stage, avoiding unnecessary materialization entirely. This is a live, load-bearing part of how Spark achieves its performance, not a legacy artifact (d is false), has nothing to do with JAR size (b), and Spark chains an arbitrary number of transformations in one lazy plan, not just one (c).

Apache Spark/core-abstractions/transformations-vs-actions

Calling df.filter(...) on a DataFrame immediately scans the underlying data and produces a filtered result in memory, before any action like .show() or .count() is called.#

Options

Show answer

False. filter is a transformation, and transformations in Spark are lazy — calling df.filter(...) only adds a node to the DataFrame's logical plan describing the predicate; it does not scan or touch the underlying data. Execution only happens once an action such as .show(), .count(), or .collect() forces the accumulated logical plan to be optimized and run.

Why:

False. filter is a transformation, and transformations in Spark are lazy: calling df.filter(...) only adds a node to the DataFrame's logical plan describing 'filter this data by this predicate' — it does not touch the underlying data at all. Nothing actually scans, reads, or filters any data until an action (.show(), .count(), .collect(), .write(...), etc.) forces the accumulated logical plan to be optimized and executed. This is what allows Catalyst to see the entire chain of transformations before running any of it and optimize across the whole plan, rather than executing each step in isolation the moment it's called.

Apache Spark/execution-model/catalyst-optimizer

What is the Catalyst optimizer's role in Spark SQL / DataFrame execution?#

Options

Show answer

The Catalyst optimizer is Spark SQL's query optimization framework: it resolves an unresolved logical plan against the schema, applies rule-based optimizations such as predicate pushdown and constant folding to the resolved logical plan, and then generates and selects among candidate physical plans before handing the result to Tungsten for code generation and execution. It is not a serialization mechanism, not an automatic caching layer, and not the cluster resource manager that allocates executors.

Why:

Catalyst is Spark SQL's query optimization framework: it takes the unresolved logical plan built from DataFrame/SQL operations, resolves references against the schema (the 'Analysis' phase), applies a rule-based optimizer to the resolved logical plan (predicate pushdown, constant folding, projection pruning, and more), then generates one or more candidate physical plans and picks among them (informed by statistics where available), before handing the chosen physical plan to Tungsten for whole-stage code generation and actual execution. It's specifically the optimization/planning layer, not serialization (b, which is closer to what Kryo/Tungsten's encoders handle), not an automatic caching mechanism (c — Spark never auto-persists a DataFrame; caching is always an explicit .cache()/.persist() call), and not cluster resource management (d, which is the job of the cluster manager — YARN, Kubernetes, or Spark's own standalone scheduler).

Apache Spark/core-abstractions/rdd-vs-dataframe-vs-dataset

In Apache Spark, why can Catalyst and Tungsten optimizations apply to DataFrames and Datasets but NOT to plain RDDs? Contrast the three abstractions in terms of schema awareness, type safety, and optimization engine support.#

Show answer

RDDs provide an untyped, object-based API with no built-in optimization engine — they are opaque bags of JVM/Python objects, so Spark's Catalyst optimizer and Tungsten execution engine cannot inspect or optimize the data transformations. DataFrames and Datasets, by contrast, expose a structured schema (column names and types) that Catalyst can inspect to build an optimized logical and physical plan, and Tungsten can apply off-heap binary encoding, code generation, and whole-stage codegen. Datasets add compile-time type safety via encoders (available in Scala/Java), combining the typed API of RDDs with the optimization benefits of DataFrames.

Why:

RDDs are opaque collections of objects with no schema information, so Catalyst cannot inspect their structure to optimize queries and Tungsten cannot apply its off-heap memory management or code generation. DataFrames are untyped but schema-aware (named columns with types), enabling full Catalyst + Tungsten optimization. Datasets (Scala/Java only) add compile-time type safety via encoders while retaining the same optimization pipeline. Python and R only have RDDs and DataFrames — no Datasets API.

Apache Spark/core-abstractions/lazy-evaluation

Which of the following statements about lazy evaluation in Apache Spark (RDD API, Spark 3.x) are true? Select all that apply.#

Options

Pick every one that applies.

Show answer

Lazy evaluation in Spark means transformations only build a lineage DAG and do not execute until an action (like collect()) is called. Spark does not automatically cache intermediate RDDs — you must explicitly persist() or cache(). Lineage enables fault tolerance: a lost partition is recomputed from the DAG rather than from replicated data.

Why:

Transformations in Spark are lazy: they only record metadata and build a lineage DAG (a). Actions such as collect() trigger the scheduler to execute all pending transformations in the lineage (b). Spark does NOT auto-cache intermediates — the developer must explicitly call persist() or cache() (c is false). Because lineage is preserved, a lost partition can be recomputed from its parent partitions via the DAG (d).

Apache Spark/core-abstractions/rdd-vs-dataframe-vs-dataset

In Apache Spark, what are the key architectural and API differences between RDDs, DataFrames, and Datasets, and how does the Catalyst optimizer factor in?#

Show answer

RDDs offer no schema or built-in optimization; you work with raw JVM/Python objects, and Spark applies no query planning. DataFrames add a schema (columns are named, typed Row objects) so the Catalyst optimizer can build a logical/physical plan and Tungsten can generate efficient bytecode. Datasets (Scala/Java only) combine the DataFrame schema with compile-time type safety via an Encoder, giving you Catalyst optimization plus static typing; in Spark 2+, DataFrame is literally a type alias for Dataset[Row].

Why:

This flashcard tests a mid-level engineer's grasp of the three core Spark abstractions: the unoptimized, schema-less RDD; the schema-aware, Catalyst-optimized DataFrame; and the type-safe Dataset that bridges both via Encoders. Knowing that DataFrame is an alias for Dataset[Row] in Spark 2+ is the detail that separates surface-level recall from genuine understanding.

Apache Spark/core-abstractions/transformations-vs-actions

The following PySpark code runs in local mode. What exact output does the print statement produce?#

from pyspark.sql import SparkSession

spark = SparkSession.builder.master("local").getOrCreate()
sc = spark.sparkContext

rdd = sc.parallelize([3, 1, 4, 1, 5, 9, 2, 6])

a = rdd.distinct().sortBy(lambda x: x).take(3)
b = rdd.filter(lambda x: x > 3).count()
c = rdd.map(lambda x: x * 2).reduce(lambda x, y: x + y)

print(a, b, c)
Show answer
[1, 2, 3] 4 62
Why:

distinct, sortBy, filter, and map are lazy transformations that only build a lineage; take, count, and reduce are actions that each trigger independent execution of that lineage. For a: the original list is [3, 1, 4, 1, 5, 9, 2, 6]; distinct removes the duplicate 1, yielding {1, 2, 3, 4, 5, 6, 9}; sortBy arranges them as [1, 2, 3, 4, 5, 6, 9]; take(3) gives [1, 2, 3]. For b: filter(lambda x: x > 3) keeps [4, 5, 9, 6], so count = 4. For c: map(lambda x: x * 2) doubles each element of the original list → [6, 2, 8, 2, 10, 18, 4, 12]; reduce sums them → 6+2+8+2+10+18+4+12 = 62. The print statement outputs [1, 2, 3] 4 62.

Apache Spark/core-abstractions/transformations-vs-actions

The following PySpark code runs in local mode. What exact output do the three print statements produce, in order?#

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder.master("local").getOrCreate()

df = spark.createDataFrame(
    [("a", 1), ("b", 2), ("c", 3), ("a", 4)],
    ["k", "v"]
)

step1 = df.filter(df.v > 1)
step2 = step1.groupBy("k").agg(F.sum("v").alias("s"))
step3 = step2.orderBy("k")

print(step3.count())
print([row.k + str(row.s) for row in step3.collect()])
print(step1.count())
Show answer
3
['a4', 'b2', 'c3']
3
Why:

All DataFrame operations before the prints are transformations—no data is processed until an action runs. step1 filters to rows with v > 1: ("b",2), ("c",3), ("a",4). step2 groups by k and sums v: a→4, b→2, c→3. step3 orders by k ascending: a, b, c. The first action step3.count() returns 3. The second action step3.collect() returns rows [(a,4),(b,2),(c,3)], yielding ['a4','b2','c3']. The third action step1.count() re-evaluates the filter lineage independently and returns 3.

Apache Spark/execution-model/partitioning-shuffle

Which of these statements about shuffles and shuffle-avoidance in Spark are accurate? Select all that apply.#

Options

Pick every one that applies.

Show answer

Wide transformations like groupByKey, join, distinct, and repartition require a shuffle to co-locate matching keys across partitions. reduceByKey typically outperforms groupByKey because it combines values locally on each partition before shuffling, sending far less data over the network. coalesce() can reduce partition count without a full shuffle by merging partitions already local to the same executors, while repartition() always triggers a full shuffle, and spark.sql.shuffle.partitions defaults to 200 for shuffle operations in Spark SQL. Many operations beyond an explicit repartition() call trigger a shuffle, including groupBy, join, distinct, and orderBy.

Why:

Wide transformations are, by definition, exactly the operations whose output partition depends on data spread across multiple input partitions, which forces a shuffle to co-locate matching keys (a). reduceByKey's map-side combine is the textbook reason it beats groupByKey for aggregation: partial sums (or whatever the reduce function combines) are computed locally before anything crosses the network, so the shuffle itself moves far less data, whereas groupByKey ships every individual value across first and only combines afterward (b). coalesce() is specifically designed to reduce partition count cheaply by merging partitions already local to the same executors when possible, avoiding a full shuffle, while repartition() (used to either increase or decrease partition count) always performs a full shuffle to redistribute data evenly (c). spark.sql.shuffle.partitions genuinely defaults to 200 and directly controls the number of partitions produced by shuffle operations in DataFrame/SQL code (d). But shuffles are triggered by many operations beyond an explicit .repartition() call — groupBy, join, distinct, and orderBy all trigger one as a side effect of the operation itself, so (e)'s claim that only .repartition() can cause a shuffle is false.

Apache Spark/spark-performance/common-pitfalls

Which of these are genuine, common Spark performance pitfalls? Select all that apply.#

Options

Pick every one that applies.

Show answer

Genuine common Spark performance pitfalls include collecting a large DataFrame to the driver and exhausting its single-JVM memory, using a Python UDF for logic that built-in Spark SQL functions could express instead, and repeatedly recomputing an expensive uncached DataFrame across multiple downstream branches due to lazy evaluation. Failing to trigger a broadcast join for a large-vs-small join, forcing an unnecessary shuffle of the large side, is another genuine pitfall. spark.sql.shuffle.partitions is a tunable configuration property whose default happens to be 200, not a hard architectural limit on the shuffle engine.

Why:

.collect() moves the entire result set into the driver's single JVM heap, so a result that was perfectly fine sharded across dozens of executors can blow past the driver's memory the moment it's collected — a very common real-world OOM (a). Python UDFs cross the JVM/Python process boundary for every row, paying serialization cost each way, and because the UDF's logic is opaque Python bytecode, Catalyst can't push predicates through it or otherwise optimize around it the way it can with a built-in expr()-based function (b). Because Spark transformations are lazy, an uncached DataFrame reused in multiple downstream branches (say, two different aggregations off the same expensive joined result) is recomputed in full for each one, unless it's explicitly .cache()d or .persist()d after the expensive part (c). And failing to get a broadcast join for a large-vs-small join means Spark falls back to shuffling the large side across the network just to co-locate it with the small side's data, when broadcasting the small table to every executor (automatic below spark.sql.autoBroadcastJoinThreshold, or forced with a broadcast hint) avoids that shuffle entirely (e). spark.sql.shuffle.partitions is a tunable configuration property, not a hard architectural limit — 200 is only its default, and it's routinely tuned up or down based on data volume and cluster size, so (d) is false.

Apache Spark/memory-management/executor-memory-model

Under Spark's unified memory manager, execution memory (for shuffles, joins, sorts, aggregations) and storage memory (for cached data) can borrow space from each other, rather than being two rigidly fixed, non-overlapping regions.#

Options

Show answer

True. Since Spark 1.6, spark.memory.fraction defines a single pool shared between execution memory and storage memory rather than two rigidly fixed regions, and either side can borrow free space from the other under memory pressure. Execution memory can forcibly evict cached storage blocks when it needs the space and none is free, since recomputing a cache is cheaper than failing a computation, but storage cannot evict blocks actively in use by a running task's execution memory.

Why:

True. Since Spark 1.6's unified memory management model, spark.memory.fraction (default 0.6 of the heap after subtracting reserved memory) is a single pool shared between execution and storage, rather than two hard-partitioned regions as in the older static memory manager. Either side can borrow free space from the other under memory pressure — if storage isn't using its share, execution can use it for a large shuffle or sort, and vice versa — with one asymmetry: execution memory can forcibly evict cached (storage) blocks if it genuinely needs the space and none is free, since a computation failing outright is worse than losing a cache that can be recomputed, but storage cannot forcibly evict blocks that are actively part of a running task's execution memory. This borrowing is exactly why cranking cache usage too high can start causing tasks to spill or slow down — the two are competing for the same underlying pool, not isolated from each other.

Apache Spark/spark-performance/common-pitfalls

This job reads a partitioned Parquet table with a country column and is meant to only scan the US partition, but the Spark UI shows every partition being read from S3 regardless of the filter. Which line prevents partition pruning and predicate pushdown from working?#

1| from pyspark.sql.functions import udf
2| from pyspark.sql.types import BooleanType
3|
4| is_us = udf(lambda country: country == "US", BooleanType())
5|
6| df = spark.read.parquet("s3://data/orders/")
7| result = df.filter(is_us(df.country))
8| result.write.parquet("s3://data/orders_us/")

Options

Show answer

Lines 1–4 and 7 — wrapping the filter condition in a Python UDF makes it an opaque black box to Catalyst; the optimizer can't see that the predicate is a simple equality on country, so it can't push it down to the Parquet reader or prune non-matching partitions, and instead reads and deserializes every row from every partition before applying the UDF row by row

Why:

Predicate pushdown and partition pruning both depend on Catalyst being able to understand the filter expression well enough to rewrite the physical scan — recognizing 'this is country == 'US'' lets it skip reading partitions whose directory-level value isn't US at all, and skip decoding column chunks a native Spark SQL filter (df.filter(df.country == "US")) makes fully visible to the optimizer. A Python UDF is opaque: Catalyst has no way to know what is_us computes, only that it's some black-box function to run per row, so it cannot push anything down or prune anything — it has to materialize and deserialize every row (paying the JVM↔Python serialization cost per row too) and evaluate the UDF against each one after the fact. Rewriting line 7 as df.filter(df.country == "US") — a native Spark SQL expression, not a UDF — restores both partition pruning and predicate pushdown. Partition pruning genuinely does work for native filters on partitioned Parquet tables (b is false), the output file format on line 8 has no bearing on how the input is read (c), and 'always reads every partition regardless of the filter' is exactly the bug this specific UDF introduces, not baseline Spark behavior (d).

Apache Spark/execution-model/catalyst-optimizer

Explain what predicate pushdown is and why writing filters as native Spark SQL/DataFrame expressions rather than as UDFs matters for it in practice.#

Show answer

Predicate pushdown is the Catalyst optimizer moving a filter condition as close as possible to the data source, ideally into the read itself, instead of reading all the data first and filtering it afterward in Spark. For a columnar format like Parquet with partition directories and column-level statistics (min/max per row group), a pushed-down filter can let the reader skip entire partitions whose directory value can't match, and skip decoding row groups whose min/max statistics prove no row inside could satisfy the predicate — dramatically reducing both I/O and CPU work before a single row is materialized in Spark. This only works when Catalyst can actually understand the filter expression well enough to translate it into something the data source connector can act on, which is true for native expressions like df.filter(df.country == 'US') but not for a Python or Scala UDF, which is an opaque black box the optimizer can't see inside — so a UDF-wrapped filter forces Spark to read and deserialize every row of every partition and evaluate the UDF row by row after the fact, throwing away the entire benefit predicate pushdown exists to provide.

Why:

This tests whether an engineer understands the mechanism, not just the buzzword: predicate pushdown only works when the optimizer can see and understand the filter well enough to hand it to the storage layer, and that visibility is exactly what a UDF destroys by being an arbitrary black-box function. It's a very common, subtle source of real production slowdowns — code that looks correct and passes tests, but silently reads 10-100x more data than necessary because a filter got expressed as a UDF instead of a native expression — which is why it's a strong senior-level probe.

Apache Spark/execution-model/catalyst-optimizer

Order the phases a Spark SQL / DataFrame query goes through, from the code being written to results being produced.#

Put these in order

Show answer

A Spark SQL query goes from an unresolved logical plan built directly from the code as written, through analysis resolving references against the schema into a resolved logical plan, through Catalyst's rule-based optimization rewriting that resolved plan, through physical planning that generates and selects among candidate physical plans, through Tungsten's whole-stage code generation compiling the chosen plan into JVM bytecode, and finally to execution across the cluster's executors. Each phase is a strict prerequisite for the next: optimization needs a schema-resolved plan to rewrite safely, physical planning needs the final optimized logical plan, and execution needs real generated code to run.

Why:

Nothing can be optimized before Spark even knows what a column reference points to, so analysis — resolving names against the actual schema — has to happen before any rewriting. Only a schema-resolved plan can be safely rewritten by rule-based optimizations like predicate pushdown, since pushing a filter down requires knowing exactly which physical column and data source it refers to, which is why logical optimization comes after analysis, not before. Physical planning only makes sense once the logical plan is already in its final, optimized form — there's no point generating physical alternatives for a plan that's about to be rewritten anyway. Code generation needs one concrete, selected physical plan to compile against, and actual execution across the cluster can only happen once real generated code exists to run — so each phase is a strict prerequisite for the one after it.

Apache Spark/spark-performance/data-skew

This join runs on a Spark 3.x cluster with Adaptive Query Execution explicitly disabled for a compliance-mandated reproducible query plan. 199 of the 200 shuffle tasks finish in under a minute; one task runs for over two hours and the whole job is blocked waiting on it. events has ~2 billion rows; roughly 40% of them share the single value user_id = -1 (a known 'unknown user' sentinel). Which line is the root cause of the straggler task?#

1| events_df = spark.read.parquet("s3://data/events/")
2| users_df = spark.read.parquet("s3://data/users/")
3|
4| joined = events_df.join(users_df, on="user_id", how="left")
5| joined.write.parquet("s3://data/enriched_events/")

Options

Show answer

Line 4 — a plain shuffle join sends every row with the same join key to the same reducer partition; with ~40% of 2 billion rows sharing user_id = -1, one partition receives a wildly disproportionate share of the data, so that single task takes far longer than the other 199 that each got a normal, evenly-sized slice — a classic case of data skew, not a general performance problem with joins

Why:

A standard shuffle join partitions both sides by the join key's hash so that matching keys land on the same reducer, and every row with user_id = -1 — roughly 800 million of the 2 billion — is one such key. All of that data is forced onto a single partition/task no matter how many total shuffle partitions exist, while the other 199 tasks split the remaining, much smaller portion of relatively evenly-distributed keys. That's exactly why 199 finish quickly and one becomes an enormous straggler: it isn't slow because joins are inherently slow, it's disproportionately large because of the skewed key distribution, which is precisely what 'data skew' means. Because AQE (which has a runtime skew-join optimization that can automatically split an oversized partition) is explicitly disabled here for reproducibility, none of that automatic mitigation is available, and a manual fix is needed — the standard techniques are salting the skewed key (appending a random suffix to user_id = -1 on both sides and joining on the composite key, then aggregating away the salt afterward) or isolating and broadcasting/handling the skewed key's rows separately from the rest of the join. File format (b, c) has no bearing on a single-task straggler caused by key distribution — a CSV read/write would still hit the identical skew on line 4 — and a 100×+ runtime disparity on one task driven by a known, massively over-represented sentinel value is not 'normal variance' that resolves itself (d).

Apache Spark/spark-performance/data-skew

What is data skew in Spark, how does it show up operationally, and name two concrete techniques to mitigate it.#

Show answer

Data skew is an uneven distribution of a join or aggregation key, where one or a few key values account for a disproportionate share of the rows relative to the rest. Because a shuffle sends all rows for a given key to the same reducer partition, a skewed key forces one task to process far more data than its peers, while the rest of the tasks finish quickly. Operationally it shows up as a small number of straggler tasks in the Spark UI that run dramatically longer than the majority of tasks in the same stage, often causing the whole job to appear stalled because it's waiting on that one task, and sometimes causing an executor OOM on the task handling the oversized partition. Two concrete mitigations: salting the skewed key, by appending a random suffix to the hot key value on both sides of the join (fanning it out across many synthetic sub-keys) and then aggregating the salt away in a later step, which spreads the hot key's rows across many partitions instead of one; and enabling Adaptive Query Execution's skew join optimization (spark.sql.adaptive.skewJoin.enabled, on by default when AQE is enabled since Spark 3.2), which detects an oversized shuffle partition at runtime from actual post-shuffle statistics and automatically splits it into smaller sub-partitions before the join.

Why:

Data skew is one of the most-asked Spark performance topics in interviews precisely because it's a real, recurring production issue that's invisible from correctness testing — the query returns the right answer, it's just catastrophically slow — and diagnosing it from symptoms (a straggler task in the Spark UI, an executor OOM on one task while the rest are fine) is a genuine on-the-job skill. Salting and AQE's automatic skew-join splitting are the two standard, complementary answers: salting is the manual technique that works everywhere including older Spark versions or when AQE is disabled for reproducibility, while AQE's runtime detection is the modern default that needs no code change but requires accurate runtime shuffle statistics to trigger.

Apache Spark/core-abstractions/rdd-vs-dataframe-vs-dataset

In Apache Spark 3.x, when you invoke a terminal action (e.g., collect()) on a Dataset[Person], the query traverses a fixed, sequential pipeline of plan transformations before any task runs on an executor. Arrange the five pipeline stages below in the exact order they occur — from the moment the action is called to the moment executor tasks begin.#

Put these in order

Show answer

The Spark 3.x execution pipeline for a Dataset action runs in this strict order: (1) unresolved logical plan from the API calls, (2) analyzed logical plan after catalog resolution, (3) optimized logical plan after Catalyst logical rewrites, (4) physical plan after physical planning and selection, (5) the physical plan is lowered into an executed RDD DAG with Tungsten whole-stage code generation. Dataset and DataFrame are declarative abstractions that never directly build RDDs — Catalyst compiles them through these phases until the final lowering produces the RDD DAG the executor runs.

Why:

Spark 3.x executes every Dataset/DataFrame action through the same fixed Catalyst pipeline. The API calls first produce an unresolved logical plan (a). The Analyzer then binds unresolved attributes against the catalog to yield the analyzed logical plan (b). Catalyst's logical optimizer rewrites that plan with rule-based and cost-based optimizations to produce the optimized logical plan (c). The SparkPlanner then enumerates physical strategies and picks one, yielding the physical plan (d). Finally, the physical plan is lowered into an RDD DAG — with Tungsten whole-stage code generation fusing adjacent operators into generated Java bytecode — and submitted to the DAGScheduler for execution (e). This ordering is strict: analysis cannot run before a logical plan exists, logical optimization cannot run before attributes are resolved, physical planning requires an optimized logical plan, and RDD DAG generation requires a concrete physical plan. The key insight tying this to the RDD-vs-DataFrame-vs-Dataset topic is that Dataset and DataFrame are higher-level declarative abstractions whose user code never directly constructs RDDs; instead, Catalyst and Tungsten compile the declarative query down through these phases into an RDD DAG that the execution engine ultimately runs.

Apache Spark/core-abstractions/lazy-evaluation

In Apache Spark, consider an RDD built from a sequence of three groupByKey operations chained together (each groupByKey introduces a shuffle) followed by a collect(). When collect() is invoked, how many stages does the DAGScheduler create, and what specific property of each parent–child dependency in the RDD lineage determines where stage boundaries are drawn? Explain why Spark defers this stage partitioning — and indeed all DAG construction — until an action is called, rather than building stages as each transformation is applied.#

Show answer

Three sequential shuffle dependencies produce four stages: three ShuffleMapStages (one per shuffle boundary) and one final ResultStage that contains the collect action. The DAGScheduler traverses the RDD lineage graph backward from the action; at each point where the dependency between a child RDD and its parent is a ShuffleDependency (a wide dependency, where partitions of the child depend on multiple partitions of the parent), it draws a stage boundary. Narrow dependencies (NarrowDependency), where each child partition depends on a fixed subset of parent partitions, remain within the same stage and are pipelined. Spark defers all of this because transformations only append to the lineage graph metadata — no computation, no DAG, and no stage construction occurs. Only when an action calls SparkContext.runJob does the scheduler materialize the DAG, partition it into stages at shuffle boundaries, and submit tasks. This laziness is what enables the scheduler to see the entire transformation chain and optimize stage boundaries, pipelining, and task placement before any data is read.

Why:

Three shuffles create three ShuffleMapStages plus one ResultStage = four stages. Boundaries are drawn at ShuffleDependency (wide) edges; narrow dependencies stay pipelined within a stage. Lazy evaluation means the lineage is only metadata until an action calls runJob, at which point the full DAG is visible and the scheduler can optimally partition it.

Apache Spark/core-abstractions/rdd-vs-dataframe-vs-dataset

In Apache Spark (3.x), DataFrame and Dataset queries are compiled through the Catalyst query optimizer pipeline before execution falls through to the RDD execution layer. Place the following five Catalyst pipeline phases in strict chronological order — from the moment a Dataset expression is first parsed to the point where Tungsten code generation produces RDD-level execution.#

Put these in order

Show answer

The Catalyst pipeline phases in chronological order are: (1) Unresolved Logical Plan from the Parser, (2) Analyzed Logical Plan after catalog resolution by the Analyzer, (3) Optimized Logical Plan after Catalyst rule-based rewrites, (4) Physical Plan after SparkPlanner strategy selection, and (5) prepared RDD execution via Tungsten whole-stage code generation. Each phase feeds strictly into the next — parse, analyze, optimize, plan, then execute — and RDDs form the lowest-level execution substrate that the compiled bytecode ultimately runs on.

Why:

The Catalyst pipeline is a strict, linear chain. The Parser first produces an Unresolved Logical Plan (a) from the Dataset expression. The Analyzer then uses the catalog to resolve relations and columns, producing the Analyzed Logical Plan (b). The Catalyst optimizer applies rule-based rewrites to yield the Optimized Logical Plan (c). The SparkPlanner then converts this into a Physical Plan (d), selecting among physical strategies. Finally, whole-stage Tungsten code generation compiles the chosen physical plan into JVM bytecode that runs as RDD transformations (e). This ordering is fixed by Spark's QueryExecution sequence: analyzed → optimizedExecutedPlan → executedPlan, and the sub-steps (parse → analyze → optimize → plan → prepare/execute) are architecturally sequential with no reordering or parallelism. RDDs sit at the bottom of this stack as the execution substrate, which is why Dataset and DataFrame benefit from Catalyst optimization that RDDs bypass entirely.

Apache Spark/execution-model/partitioning-shuffle

The following Spark (Scala) code hash-partitions two pair RDDs by key into 200 partitions each and then joins them. The developer expects the join to be shuffle-free because both sides share the same HashPartitioner and partition count. However, a Spark job inspection reveals that the join stage still triggers a full shuffle of transformed. Identify the single buggy line that causes the unnecessary shuffle.#

import org.apache.spark.HashPartitioner

val pairs = sc.parallelize(1 to 1000000).map(i => (i % 100, i))
val partitioned = pairs.partitionBy(new HashPartitioner(200))

// Transform values, keeping the same keys — partitioner should be preserved
val transformed = partitioned.map { case (k, v) => (k, v * 2) }

val lookup = sc.parallelize(0 until 100)
                .map(k => (k, k.toString))
                .partitionBy(new HashPartitioner(200))

// Both sides are hash-partitioned by key into 200 partitions — expect no shuffle
val result = transformed.join(lookup)
Show answer

The bug is on line 7.

Why:

In Spark's RDD API, the map transformation always sets the resulting RDD's partitioner to None, even when the input RDD has an explicit partitioner and the map function preserves the key. Spark makes no assumptions about whether map changes the key. Only mapValues and flatMapValues—which are contractually guaranteed to leave keys untouched—preserve the partitioner. On line 7, partitioned.map { case (k, v) => (k, v * 2) } produces an RDD with partitioner = None, even though the underlying data is physically still hash-partitioned by key into 200 partitions. Consequently, the join on line 14 cannot recognize transformed as co-partitioned with lookup and triggers a full shuffle of transformed's data. The fix is to replace map with mapValues(v => v * 2), which preserves the HashPartitioner(200) and makes the join shuffle-free.

Related interview questions

The other 75 questions

This page shows 25. A free account opens the rest and marks your answers, which a page of answers can't do. Miss one and it returns a few days later, then a week after that, until you stop missing it.

Start free

Free · the whole bank · 100 graded answers per 30 days · written feedback and job-ad quizzes on the paid plan

What moved, monthly

One email a month when the bulletin comes out: what moved in the markets we track, and the new question topics we published. Confirm your address to join. Unsubscribe any time.