Spark & Hadoop Interview Question Bank
Scope — the classic Spark / Hadoop interview questions, each answered in the few sentences you would say out loud, plus the troubleshooting walk-throughs, pipeline-design prompts and study plan that turn them into a drill. The mechanism behind each answer lives in the deep dive
../de/spark.md; this file links there rather than repeating it. See also:../de/spark.md— Spark internals in depth;../de/hadoop.md— HDFS, YARN and MapReduce in depth;../de/stream_pipeline.md— streaming semantics and engines;./faq_hadoop.mdand./faq_mapreduce.md— reading lists.
Every answer opens with a bold sentence — the one to say first — then the two or three facts an interviewer expects next. Configuration names and defaults are Spark 3.5’s. Section 0 pairs this file with the deep dive and sets the review order; section 14 is the study plan and the mock question bank.
0) How to Use the Two Spark Docs
0-1) Which doc does what
Two documents cover Spark here. This one, the question bank, says what to answer;
de/spark.md, the deep dive, explains why the answer is true. Use them as
a pair rather than one after the other.
| This file: the question bank | de/spark.md: the deep dive |
|
|---|---|---|
| Each section gives | the few sentences you say out loud | the mechanism, and the trade-off the interviewer pushes on next |
| Only here | troubleshooting walk-throughs (§12), design prompts (§13), study plan and mock bank (§14) | PySpark (§10), writes and table formats (§11), tuning checklist (§13), Spark UI symptoms (§14) |
| Open it when | you are drilling, or it is the night before | an answer here does not survive “why?” |
| Sections run | in the order interviews ask | in the order Spark is built |
The two files number their sections in different orders, so neither works as a front-to-back
read on its own. 0-2 lines them up. For neighbouring topics, see
de/hadoop.md for HDFS and YARN, de/stream_pipeline.md
for streaming beyond Spark, and the faq_hadoop.md and
faq_mapreduce.md reading lists.
0-2) Review order
Take the steps in order. For each step, answer the “Say it” sections out loud, read the “Understand it” sections for whatever you could not explain, then answer again.
| Step | Phase | Topic | Say it — this file | Understand it — de/spark.md |
|---|---|---|---|---|
| 1 | 1 · Foundations | What Spark is, and Spark vs MapReduce | 1-1, 1-2, 1-4 | 1-1, 1-2, 1-3 |
| 2 | Driver, executors, spark-submit |
2-1, 2-2, 2-3 | 2-1, 2-2 | |
| 3 | Job → stage → task, narrow vs wide, partitions | 3-1, 3-2, 3-3, 3-4 | 2-3, 3-3, 6-1 | |
| 4 | RDD vs DataFrame, transformations vs actions | 4-2, 4-3, 4-5 | 3-1, 3-2, 3-4 | |
| 5 | 2 · Performance core | Shuffles | 5-1, 5-2, 5-3 | 5-1, 5-2, 5-3, 9-5 |
| 6 | repartition, coalesce, file layout, small files |
3-5, 12-3 | 6-2, 6-3, 6-4 | |
| 7 | Join strategies and data skew | 5-5, 6-1, 6-2, 12-2 | 7-1, 7-2, 7-3 | |
| 8 | Caching and checkpointing | 7-1, 7-2, 7-3 | 8-1, 8-2, 8-3 | |
| 9 | Memory, sizing and OOM | 8-1, 8-2, 8-3 | 9-1, 9-2, 15-1 | |
| 10 | 3 · Engine and production | Fault tolerance | 9-1, 9-2 | 2-4 |
| 11 | Catalyst, AQE, reading a plan | 10-1, 10-2, 10-3, 10-4 | 4-1, 4-2, 4-3, 4-4 | |
| 12 | PySpark: workers, UDFs, Arrow | — | 10-1, 10-2, 10-3 | |
| 13 | Writes, save modes, table formats | — | 11-1, 11-2, 11-3, 11-4 | |
| 14 | Structured Streaming | 11-1, 11-2, 11-3 | 12-1, 12-2, 12-3, 12-4, 12-5, 12-6 | |
| 15 | Tuning and the Spark UI | 12-1, 12-4 | 13-1, 13-2, 14-1, 14-2, 15-2 | |
| 16 | 4 · Rehearse | Pipeline design | 13-1, 13-2 | 15-3, 16-2 |
| 17 | Mock round: answer out loud, then self-check | 14-2, 14-3 | 16-1 |
- Steps 1–9 are the P0 and P1 core from 14-1. Most interviews never get past them.
- Steps 12 and 13 exist only in the deep dive. PySpark and table-format questions come up in data-engineering loops far more than in general SWE ones.
0-3) How much time do you have?
Spend the time on the steps, not on the files front to back.
| Time | Plan |
|---|---|
| One evening | Steps 1–9, “Say it” column only, then the mock bank 14-2 |
| A week | Day 1: phase 1. Days 2–3: phase 2. Days 4–5: phase 3. Day 6: phase 4. Day 7: redo every step you stumbled on |
| The night before | 14-2 out loud and the 14-3 checklist; nothing new |
1) Spark, Hadoop and MapReduce Priority 4 of 5 — High value — a gap here costs you rounds
1-1) What is Apache Spark?
Spark is a distributed compute engine: you describe a computation over a dataset, Spark turns it into a plan of parallel tasks, runs them across a cluster, and recomputes any lost piece from its lineage.
- Unified — batch SQL / DataFrames, Structured Streaming, MLlib and GraphX share one engine.
- Not a database and not a queue — it stores nothing durably. It reads from and writes to HDFS, S3 / GCS / ADLS, JDBC, Kafka and table formats such as Delta and Iceberg.
- Languages — Scala, Java, Python (PySpark), SQL and R.
| Property | What it means |
|---|---|
| Distributed | data is split into partitions processed in parallel on many executors |
| In-memory first | intermediate results stay in memory between stages when they fit |
| Lazy | transformations only build a plan; an action runs it |
| Fault tolerant | a lost partition is recomputed from lineage, not restored from a replica |
| Horizontally scalable | more executors means more parallel tasks |
1-2) Spark vs Hadoop MapReduce
MapReduce writes to HDFS between every job, so a ten-step pipeline is ten disk round trips; Spark plans the whole pipeline as one DAG and keeps intermediate data in memory or local disk between its stages.
| Spark | Hadoop MapReduce | |
|---|---|---|
| Execution model | DAG of stages, narrow steps fused into one task | one map phase, one reduce phase per job |
| Intermediate data | memory, spilling to local disk | HDFS between jobs, local disk inside a job |
| Iterative work (ML, graphs) | fast — reuse a cached dataset | slow — re-read from HDFS each iteration |
| APIs | DataFrame, SQL, RDD, streaming, ML | map and reduce functions, plus Hive / Pig on top |
| Streaming | Structured Streaming (micro-batch) | not suited to it |
| Storage | none of its own | ships with HDFS |
- Hadoop is more than MapReduce. It is HDFS (storage) + YARN (resources) + MapReduce (compute). Spark replaced the compute layer and often still runs on YARN over HDFS.
- “Spark needs everything in memory” is a myth. Operators spill to disk when memory runs
out, and a dataset is in memory only if you
cache()it. Data far larger than the cluster’s memory is routine for Spark — it just runs slower. So “use MapReduce when data is very large” is not a reason anyone gives today.

1-3) What is the MapReduce programming model?
Map turns each input record into key-value pairs, the framework groups all pairs by key (the shuffle), and reduce folds each key’s values into a result.
- You write only
mapandreduce; partitioning, sorting, shuffling and retries are the framework’s job. - A combiner is an optional map-side reduce — it shrinks what the shuffle moves, which is
exactly what
reduceByKeydoes for you in Spark (see 4-5). - Strengths: a simple model, scales linearly, survives node failure. Weaknesses: verbose, one map + one reduce per job, and every job round-trips through HDFS.




1-4) Why is Spark usually faster than MapReduce?
Because it avoids the per-job HDFS round trip and plans the whole computation at once — not because everything lives in memory.
- One DAG, not a chain of jobs — a multi-step pipeline is one Spark job whose stages pass data through local shuffle files, not through replicated HDFS writes.
- Pipelining — narrow transformations (
map,filter,select) are fused into one task per partition, so the rows are never materialised between them. - Caching — iterative algorithms reuse a dataset from memory instead of re-reading it.
- The optimiser — Catalyst rewrites the plan (pushdown, pruning, join choice) and Tungsten generates compact JVM code over binary rows.
- Cheap tasks — executors are long-lived JVMs that run many tasks; MapReduce starts a JVM per task.
1-5) Is MapReduce still worth learning?
Yes, as a model — map, shuffle by key, reduce is exactly what a Spark groupBy does — but
not as an API you will write.
- Every distributed aggregation, join and sort is still “partition by key, then combine”, so the shuffle questions in section 5 are MapReduce questions in disguise.
- The engines on top moved on: Hive runs on Tez rather than MapReduce, and Pig is effectively retired. HDFS and YARN are still common, especially on-premise.
1-6) What are Spark’s weaknesses?
Shuffles and memory pressure dominate its cost, it is micro-batch rather than truly per-record for streaming, and it has many knobs.
- Memory hungry — wide operations, caching and broadcasts all compete for executor memory; OOMs and spills are the most common production failures.
- Small files — many writers produce many small files unless output is sized deliberately.
- Streaming latency — Structured Streaming’s default trigger is micro-batch, so latency is in the hundreds of milliseconds to seconds; Flink is the usual choice below that.
- Python overhead — row-at-a-time Python UDFs serialise every row to a Python process.
- Tuning surface — partition counts, executor sizing and join strategies matter, and the wrong defaults are expensive.
1-7) When would you not use Spark?
When the data fits on one machine, when you need per-event low latency with large state, or when the warehouse can already run the SQL.
| Need | Better fit | Why |
|---|---|---|
| TB-scale batch ETL, large joins, ML feature pipelines | Spark | mature batch engine, rich APIs |
| Sub-second, stateful, event-time streaming | Flink | record-at-a-time, first-class state and event time |
| Stream processing inside a Kafka-centric service | Kafka Streams | a library, no cluster to run |
| One pipeline definition for batch and streaming | Apache Beam (on Dataflow / Flink / Spark) | portable programming model |
| SQL over data already in the warehouse | BigQuery / Snowflake / Redshift | no cluster to size or tune |
| A few GB on one machine | pandas / Polars / DuckDB | no distribution overhead |
Spark vs Flink in one line each: Spark is batch-first with micro-batch streaming and the
larger batch ecosystem; Flink is streaming-first with record-at-a-time processing,
checkpointed state and stronger event-time handling. More in ../de/stream_pipeline.md.
2) Architecture and Deployment Priority 5 of 5 — Must know — expect it in almost every loop
2-1) Driver, executors, workers and the cluster manager
The driver plans and schedules; executors run tasks and hold cached data; the cluster manager hands out the machines; a worker is a node that hosts executors.
| Component | What it does |
|---|---|
| Driver | runs your main(), holds the SparkSession, builds the plan, splits it into jobs, stages and tasks, schedules tasks and tracks their results |
| Executor | a JVM on a worker node that runs tasks (one per core at a time), stores cached partitions and serves shuffle files |
| Cluster manager | allocates CPU and memory to applications — Standalone, YARN or Kubernetes |
| Worker node | any machine that can host executors |
- The driver is a process, not a node, and it is not the master. In
clientmode it runs where you submitted; inclustermode the cluster manager starts it inside the cluster. Several applications — each with its own driver — share one cluster manager. - The driver does not process your data — unless you pull it there:
collect()andtoPandas()send the whole result to the driver, which is the classic driver OOM. - Executors are per application. Two applications never share an executor, so they cannot share a cache either.

Deep dive: ../de/spark.md §2-1.
2-2) What happens after spark-submit?
The driver starts, asks the cluster manager for executors, turns your code into a DAG of stages when an action runs, and sends each stage’s tasks to the executors until the job ends.
spark-submitlaunches the driver (locally inclientmode, in the cluster inclustermode).- The driver creates the
SparkSession/SparkContextand registers with the cluster manager. - The cluster manager launches executors on worker nodes; they register back with the driver.
- Your code runs. Transformations only build a logical plan.
- An action creates a job. Catalyst optimises the plan into a physical plan, and the DAGScheduler cuts it into stages at every shuffle.
- The TaskScheduler sends each stage’s tasks (one per partition) to executors, preferring executors that already hold the data (locality).
- Executors run the tasks, write shuffle output for the next stage, and report status. Failed tasks are retried (see 9-1).
- When
main()finishes orspark.stop()is called, the executors are released.
User code ─► SparkSession ─► logical plan ─► Catalyst ─► physical plan
│
executors ◄─ TaskScheduler ◄─ stages ◄─ DAGScheduler
│
└─► results to the driver, or output to storage
2-3) The spark-submit command, and adding external jars
Name the cluster manager with --master, where the driver runs with --deploy-mode, size
the driver and executors, and ship dependencies with --jars or --packages.
./bin/spark-submit \
--master yarn \
--deploy-mode cluster \
--driver-memory 4g \
--executor-memory 8g \
--executor-cores 4 \
--num-executors 20 \
--conf spark.sql.shuffle.partitions=400 \
--jars /libs/postgresql.jar,/libs/extra.jar \
--packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.1 \
--class com.example.DailyEtl \
daily-etl.jar 2026-11-06
--jarstakes a comma-separated list of local or remote jars, added to both the driver and executor classpaths.--packagesresolves Maven coordinates (and their transitive dependencies) at submit time.- For PySpark,
--py-filesships.py/.zip/.eggfiles; a packed virtual environment goes with--archives. - A fat (uber) jar that bundles its dependencies avoids classpath surprises in production.
2-4) SparkSession vs SparkContext
SparkSession (Spark 2.0+) is the single entry point for DataFrames, SQL and the catalog;
it wraps the older SparkContext, which is still the entry point for RDDs, broadcasts and
accumulators.
SparkSessionreplacedSQLContextandHiveContext;enableHiveSupport()adds the Hive metastore.- One
SparkContextper JVM;spark.sparkContextreaches it from a session. - In
spark-shellandpysparkthe objects are pre-created assparkandsc.
# python
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("daily-etl").getOrCreate()
sc = spark.sparkContext # the underlying SparkContext
2-5) Cluster managers: Standalone, YARN, Kubernetes (and Mesos)
The cluster manager only allocates resources — the driver still schedules the tasks — so the choice is about where the cluster already lives.
| Manager | Use it when | Notes |
|---|---|---|
| Standalone | a dedicated Spark-only cluster | Spark’s own master and workers; simplest |
| YARN | a Hadoop cluster (EMR, on-prem) | each application gets an ApplicationMaster; external shuffle service available |
| Kubernetes | a container platform | driver and executors are pods; dynamic allocation uses shuffle tracking |
| Mesos | legacy only | deprecated in Spark 3.2 and removed in 4.0 |
--master local[*] runs everything in one JVM with one thread per core — for tests and
notebooks, not a cluster manager.
2-6) How do you make the Standalone master highly available?
Run standby masters coordinated by ZooKeeper; if the active master dies, a standby takes over and the running applications keep going.
- ZooKeeper mode (
spark.deploy.recoveryMode=ZOOKEEPER) — production. Leader election picks the active master; workers and applications re-register with the new one. Running applications are unaffected, only new submissions wait for the failover. - File-system mode (
spark.deploy.recoveryMode=FILESYSTEM) — single-node recovery: the master’s state is written to a local directory and recovered when it restarts. - On YARN or Kubernetes this question disappears: their own control planes provide HA.
Reference: Spark Standalone — High Availability.
3) Jobs, Stages, Tasks and Partitions Priority 5 of 5 — Must know — expect it in almost every loop
3-1) Application, job, stage and task
An application is one SparkSession; every action starts a job; the job is cut into stages
at each shuffle; each stage runs one task per partition.
From largest to smallest:
Application → Job → Stage → Task
1 : many 1 : many 1 : many 1 task : 1 partition
| Level | Created by | Count |
|---|---|---|
| Application | one SparkSession / SparkContext |
one per spark-submit |
| Job | an action — count, collect, show, write, foreach |
one per action (some, like show or schema inference, run more) |
| Stage | a shuffle boundary inside the job | number of shuffles + 1 |
| Task | one partition of the stage | partitions of that stage |
- A task runs the stage’s whole fused pipeline over one partition —
read → filter → mapis one task per partition, not three. - Each task runs on one executor core. The scheduler submits a stage as a TaskSet — that is how a stage is scheduled, not what it is.
Worked example:
# python
df = spark.read.parquet("s3://bucket/events/") # 400 input partitions
result = (df.filter("age > 18")
.groupBy("country")
.count())
result.show() # the action: one job
Job 0 (show)
├── Stage 0: scan → filter → partial count 400 tasks, writes shuffle files
│ ── shuffle by country (Exchange) ──
└── Stage 1: final count per country spark.sql.shuffle.partitions tasks,
which AQE usually coalesces to a few
Catalyst, AQE and the data source can change the exact counts — explain() and the Spark UI
are the ground truth.


Is a task a process?
No. The executor is the process (one JVM per executor); a task is a unit of work that runs on one of the executor’s threads.
| Executor | Task | |
|---|---|---|
| What it is | a JVM process on a worker node | the stage’s pipeline applied to one partition |
| Lives for | the whole application, unless dynamic allocation releases it | until its partition is done: milliseconds to minutes |
| Memory | its own heap, spark.executor.memory |
shares that heap with every other task running in the executor |
| How many | spark.executor.instances, or whatever dynamic allocation picks |
one per partition of the stage |
- The driver serialises each task (the closure plus the partition it should read) and sends it to an executor, which runs it on a thread from its task pool. A task never needs a new executor JVM, and when a task finishes the executor takes the next one.
- Tasks in one executor share its heap, its cached blocks and its broadcast variables. That is why one oversized task can run the whole executor out of memory and fail the tasks next to it.
- In OS terms: the application is the program, the executor is a process, and a task is a job handed to one of that process’s worker threads.
- PySpark adds one wrinkle. A task that runs Python code (a Python UDF, or an RDD lambda)
hands it to a Python worker process the executor starts beside its JVM. A DataFrame task
that uses only built-in expressions never needs one. Workers are reused by default
(
spark.python.worker.reuse=true). Withfalse, each such task starts its own worker. Either way the driver still schedules and retries the task, and the executor JVM runs it and reports its status.
How many tasks run at once?
Each executor can run at most ⌊spark.executor.cores ÷ spark.task.cpus⌋ tasks at a
time. The division rounds down: 5 cores with spark.task.cpus=2 give 2 slots, not 2.5.
Because spark.task.cpus defaults to 1, the cluster’s ceiling on concurrent tasks is about
executors × cores per executor. A stage with fewer tasks than slots leaves the rest idle.
Two executors with two cores each give four slots, so a stage of eight tasks runs in about two waves:
Stage 0: 8 tasks, 4 slots (2 executors x 2 cores)
slot 1 slot 2
Executor 1 T0 -> T4 T1 -> T5
Executor 2 T2 -> T6 T3 -> T7
wave 1: T0-T3 wave 2: T4-T7 8 tasks / 4 slots = 2 waves
- “Waves” is an approximation. A slot takes the next pending task as soon as it is free,
without waiting for the others, and the scheduler prefers the executor that already holds
the data (
spark.locality.wait), so the real assignment is not strict round-robin. - The partitions set the task count (3-4); the cluster sets the slot count. Aim for at least two to three tasks per core (12-4), so that no slot sits idle and the last wave is not a single straggler.
If you add executors, do you get more tasks?
Usually not. The number of tasks comes from the number of partitions; more executors only add slots, so the same tasks finish in fewer waves.
| Before | After | |
|---|---|---|
| Executors | 2 | 10 |
| Cores per executor | 2 | 2 |
| Partitions in the stage | 100 | 100 |
| Tasks in the stage | 100 | 100 |
| Concurrent slots | 4 | 20 |
| Waves (tasks / slots) | 25 | 5 |
- More executors help only while there are more tasks than slots. A stage with 8 partitions
keeps 8 of the 20 slots busy and leaves 12 idle. To use them, raise the partition count:
repartition,spark.sql.shuffle.partitions, or a smallerspark.sql.files.maxPartitionBytesfor the scan. - That is why the answer is “usually” and not “never”. When
spark.default.parallelismis unset, it defaults to the total cores registered on the cluster, sosc.parallelizewith no slice count gets more partitions, and more tasks, on a bigger cluster. Shuffles mostly do not change: with that setting unset, an RDDreduceByKeykeeps the largest upstream partition count, and DataFrame shuffles usespark.sql.shuffle.partitions.
Where each level shows up in the Spark UI
The UI follows the same hierarchy. The Jobs tab has one row per job, a job’s page lists its stages, a stage’s page has one row per task, and the Executors tab shows the slots the tasks ran in.
Screenshots are from the Apache Spark docs, Web UI
(Apache License 2.0). The demo runs in local mode, so its only executor is the driver.
- Jobs tab: one row per action. Job 0 is
count at web-ui-docs-demo.py:25. A Stages cell like1/1 (1 skipped)means the job reused shuffle output an earlier job had already written, so that stage did not run again (3-2).

- Job page: the job’s stages. Job 0 has no shuffle, so it has one stage. That stage’s
5/5tasks are its five partitions.

- Stage page: one row per task.
Indexis the partition the task processed, andExecutor IDis the executor that ran it. The Summary Metrics row is where skew shows up: aMaxduration far above theMedian(6-1).

- Executors tab: the slots.
Coresis the executor’s core count. With the defaultspark.task.cpus=1, that is also how many tasks it can run at once.Active Tasksis how many it is running now. Here 37 tasks went through one executor with 4 cores, which shows that a task is a unit of work, not a process.

3-2) How does Spark split a job into stages?
The DAGScheduler walks back from the action and cuts the graph at every wide (shuffle) dependency; everything between two cuts is one stage.
- Narrow dependencies stay inside a stage and are pipelined.
- Stages that feed a shuffle are shuffle map stages; the last stage, which produces the action’s result, is the result stage.
- A stage runs only after all its parent stages finish, because it reads their shuffle output.
- If shuffle output is already there from an earlier job, the stage is skipped — it shows greyed out in the Spark UI.
3-3) Narrow vs wide dependencies
Narrow: each parent partition feeds at most one child partition, so no data moves. Wide: a parent partition feeds many child partitions, which requires a shuffle and a new stage.
| Narrow | Wide | |
|---|---|---|
| Examples | map, filter, flatMap, select, union, coalesce |
groupByKey, reduceByKey, distinct, repartition, most joins |
| Data movement | none | shuffle over the network |
| Stage boundary | no — pipelined | yes |
| Recovery of a lost partition | recompute one parent partition | may recompute many parent partitions |
- A join whose inputs are already co-partitioned (same partitioner, same partition count) is narrow — no shuffle is needed.
- Judge from the physical plan, not the API name: an
Exchangenode is a shuffle.

3-4) What is a partition?
A partition is one chunk of a distributed dataset and the unit of parallelism: one task processes one partition on one core.
- Partition count sets parallelism — 4 partitions on 16 cores leaves 12 cores idle.
- Partition size sets per-task memory and time — aim for roughly 100–200 MB per partition; far smaller wastes time on scheduling, far larger spills or OOMs.
- Where counts come from — file scans split input into about
spark.sql.files.maxPartitionBytes(128 MB) each; shuffles producespark.sql.shuffle.partitions(200) partitions unless AQE coalesces them; RDD operations default tospark.default.parallelism. - Locality — the scheduler prefers to run a task where its partition’s data already is.

3-5) repartition vs coalesce
repartition(n) does a full shuffle and can raise or lower the count with evenly sized
output; coalesce(n) merges existing partitions without a shuffle and can only lower it.
coalesce(2) — no shuffle, neighbours merged repartition(3) — full shuffle, rebalanced
[P1] ─┐ [P1] ─┐ ┌─► [N1]
[P2] ─┴─► [M1] [P2] ─┼─┼─► [N2]
[P3] ─┐ [P3] ─┘ └─► [N3]
[P4] ─┴─► [M2]
repartition(n) / repartition(col) |
coalesce(n) |
|
|---|---|---|
| Shuffle | always | no |
| Raise the count | yes | no |
| Output balance | even (hash or round-robin) | as uneven as the inputs |
| Typical use | fix skewed or too few partitions; cluster by a column before writing | reduce output file count after a big filter |
- Trap:
coalesce(1)before a write pulls the whole upstream stage down to one task, not just the write. If upstream work is heavy,repartition(1)keeps the work parallel and pays one shuffle instead. repartitionAndSortWithinPartitions(partitioner)(RDD) repartitions and sorts inside the shuffle itself — cheaper than arepartitionfollowed by a sort.
3-6) How do you optimise with a custom partitioner?
Partition both sides of repeated joins or lookups by the same key once, so later operations on that key find matching rows already co-located and skip the shuffle.
// scala — RDD API
import org.apache.spark.HashPartitioner
val users = usersRdd.partitionBy(new HashPartitioner(200)).persist()
val joined = users.join(eventsRdd) // only eventsRdd is shuffled; users stays put
- Persist after
partitionBy, or the partitioning is recomputed — and lost — every time. mapdrops the partitioner (keys might change);mapValuesandflatMapValueskeep it.- DataFrames get the same effect from bucketing (
bucketByon write) for repeated joins, and fromrepartition(col)within one job.
4) RDDs, DataFrames and Operators Priority 4 of 5 — High value — a gap here costs you rounds
4-1) What is an RDD, and how is it different from HDFS blocks?
An RDD (Resilient Distributed Dataset) is an immutable, partitioned collection plus the recipe — its lineage — for recomputing any partition, so a lost partition is rebuilt rather than restored from a replica.
- Resilient — fault tolerance through lineage. Distributed — partitions spread across executors. Dataset — a collection of records.
- An RDD is lazy and logical: it is a plan, not data in memory. It is materialised only
when an action runs, and kept only if you
cache()it. - HDFS vs RDD: HDFS stores data durably as replicated blocks (three copies by default); an RDD is a computation over such data, and survives failure by recomputing, not replicating. The two work together — RDD partitions are often created one per HDFS block, so tasks can run where the block lives.
4-2) RDD vs DataFrame vs Dataset
Use DataFrames (or Spark SQL) by default: they carry a schema, so Catalyst and Tungsten can optimise them. Drop to RDDs only for logic the DataFrame API cannot express.
| RDD | DataFrame | Dataset | |
|---|---|---|---|
| Schema | none — opaque objects | yes — named, typed columns | yes |
| Optimisation | none — runs your lambdas as written | Catalyst + Tungsten | Catalyst + Tungsten (lambdas are opaque) |
| Type safety | compile time (Scala) | runtime — a wrong column fails at analysis | compile time (Scala / Java) |
| Languages | all | all | Scala and Java only |
| Use for | low-level control, unstructured data | almost everything | typed Scala pipelines |
In Scala a DataFrame is Dataset[Row]. PySpark has no typed Dataset — a PySpark
DataFrame is the untyped API. Deep dive: ../de/spark.md §3-1.
4-3) Transformations, actions and lazy evaluation
Transformations return a new dataset and only add a step to the plan; actions return a value to the driver or write output, and they are what make Spark run anything.
| Transformations (lazy) | Actions (run a job) |
|---|---|
map, flatMap, filter, select, withColumn |
count, collect, take(n), first |
groupBy, join, distinct, union, sample |
show, reduce, foreach |
repartition, coalesce, sortBy |
write…save, saveAsTextFile |
Why lazy? Seeing the whole chain before running lets Spark fuse narrow steps into one pass, push filters and column selection into the scan, pick join strategies, and skip work whose result is never used.
# python
df = spark.read.parquet("s3://bucket/events/")
adults = df.filter("age > 18") # nothing runs
slim = adults.select("user_id", "age") # nothing runs
slim.show() # one job: scan only two columns, filter in the scan
- “Spark operators” (a common phrasing) means exactly these two families.
- Because nothing runs until the action, an error in a transformation surfaces at the action, often lines later — a frequent source of confusion when debugging.
4-4) map vs flatMap, map vs mapPartitions, reduce vs reduceByKey
map is one-to-one, flatMap is one-to-many; mapPartitions calls your function once per
partition instead of once per row; reduce is an action returning one value, reduceByKey
is a transformation returning one value per key.
# python
lines = sc.parallelize(["a b", "c"])
lines.map(lambda s: s.split(" ")).collect() # [['a', 'b'], ['c']]
lines.flatMap(lambda s: s.split(" ")).collect() # ['a', 'b', 'c']
def enrich(rows):
client = make_client() # one expensive setup per partition, not per row
for row in rows:
yield client.lookup(row)
enriched = records.mapPartitions(enrich)
mapPartitionspays off when there is per-call setup — a DB connection, a model load. Its trap: building a list of the whole partition instead of yielding row by row loads the partition into memory and can OOM the executor.reduce(f)brings one value to the driver;reduceByKey(f)stays distributed and shuffles.
4-5) reduceByKey vs groupByKey vs combineByKey
Prefer reduceByKey: it combines values on the map side before the shuffle, so only one
partial result per key per partition crosses the network. groupByKey ships every value and
then holds all of a key’s values in memory at once.
// scala
val sums1 = pairs.reduceByKey(_ + _) // map-side combine, then shuffle
val sums2 = pairs.groupByKey().mapValues(_.sum) // shuffles every value; a hot key can OOM
combineByKey(createCombiner, mergeValue, mergeCombiners)is the general form both are built on; use it when the result type differs from the value type — an average needs(sum, count)per key.aggregateByKey(zero)(seqOp, combOp)is the same idea with a zero value.groupByKeyis right only when you truly need every value together — and then watch skew.- In DataFrames this choice disappears:
groupBy().agg()does partial aggregation automatically (HashAggregate→Exchange→HashAggregatein the plan).
4-6) What does polyglot mean for Spark?
One engine, several language front ends: Scala, Java, Python, R and SQL all build the same logical plan, so a DataFrame query runs at the same speed whichever language wrote it.
The exception is code Spark cannot see into: a Python UDF or a Python RDD lambda runs in a
separate Python worker, row by row, with serialisation in between. Built-in functions and
pandas (Arrow) UDFs avoid most of that cost — see ../de/spark.md §10-2.
4-7) What is a sparse vector?
A vector that stores only its non-zero entries — size, indices, values — instead of every element; MLlib uses it for high-dimensional features such as one-hot or TF-IDF columns.
# python
from pyspark.ml.linalg import Vectors
dense = Vectors.dense([0.0, 3.0, 0.0, 4.0])
sparse = Vectors.sparse(4, [1, 3], [3.0, 4.0]) # the same vector, two stored values
5) Shuffles, Broadcasts and Joins Priority 5 of 5 — Must know — expect it in almost every loop
5-1) What is a shuffle, and what triggers one?
A shuffle redistributes rows across partitions so that all rows with the same key land together; it is triggered by grouping, joining, de-duplicating, sorting or repartitioning on a key.
Before (partitioned by file) groupBy("user_id").sum("amount") After
P0: (A,100) (B,200) every map task writes its rows P0': A → 400
P1: (A,300) (C,400) ──────► bucketed by hash(user_id), every ──► P1': B → 700
P2: (B,500) reduce task fetches its bucket P2': C → 400
- Triggers:
groupBy/*ByKey, joins (unless broadcast or co-partitioned),distinct,orderBy,repartition, window functions withpartitionBy. - In the physical plan a shuffle is an
Exchangenode; in the Spark UI it is the Shuffle Write / Shuffle Read columns of a stage.
5-2) Why is a shuffle expensive, and how is it implemented?
Every shuffled row is serialised, written to local disk, sent over the network and read back — and the map side has to finish before the reduce side can start.
- Sort-based shuffle (the default): each map task sorts its output by target partition and writes one data file plus one index file, instead of one file per reducer. The older hash shuffle created M × R files and was removed.
- Bypass-merge path: when there are at most
spark.shuffle.sort.bypassMergeThreshold(200) reduce partitions and no map-side aggregation, Spark writes one file per reducer and concatenates them, skipping the sort. - Costs: serialisation CPU, disk I/O, network I/O, and memory for sort buffers — which spill to disk when full.
spark.shuffle.file.buffer(32 KB) is the in-memory buffer per shuffle file writer; raising it to 64 KB–1 MB cuts disk syscalls on very large shuffles.- The external shuffle service serves shuffle files after the executor that wrote them is gone, which is what makes dynamic allocation safe on YARN.
Deep dive: ../de/spark.md §5-1, and
§9-5 for one row’s path through serialisation, disk and network.
5-3) How do you minimise data transfer in a Spark job?
Move less data: filter and select early, broadcast small tables, combine before shuffling, and shuffle once rather than repeatedly.
- Filter early, select only needed columns — with Parquet / ORC, Catalyst pushes these
into the scan so the data is never read. Check the plan for
PushedFilters. - Broadcast join a small side instead of shuffling the big one (5-5).
- Combine on the map side —
reduceByKeyovergroupByKey; DataFrame aggregations do this for you. - Reuse partitioning — partition or bucket once by the join key when the same key is joined repeatedly (3-6).
- Avoid needless
repartition/distinct/orderBy— each is a full shuffle. - Use a compact serialiser — Kryo for RDD jobs; DataFrames already use Tungsten’s binary format.
Accumulators, sometimes listed here, do not reduce data transfer — they are write-only counters sent back to the driver.
5-4) Broadcast variables and accumulators
A broadcast variable ships a read-only value to each executor once instead of with every task; an accumulator is a counter that tasks add to and only the driver reads.
# python
country_names = sc.broadcast({"TW": "Taiwan", "US": "United States"})
bad_rows = sc.accumulator(0)
def label(row):
if row.country not in country_names.value:
bad_rows.add(1)
return None
return (row.user_id, country_names.value[row.country])
labelled = events.rdd.map(label).filter(lambda r: r is not None)
labelled.count() # the action; bad_rows.value is now filled in
- A broadcast must fit in driver and executor memory;
unpersist()/destroy()frees it. - Accumulator trap: updates made inside a transformation can be counted more than once
if a task is retried or a stage recomputed; only updates inside an action (
foreach) are guaranteed exactly once. Treat transformation-side counts as approximate.
5-5) Broadcast join vs shuffle joins
If one side is small, broadcast it and join without shuffling the large side; otherwise Spark shuffles both sides by the join key and sort-merges them.
| Strategy | When Spark picks it | Shuffle |
|---|---|---|
| Broadcast hash join | one side under spark.sql.autoBroadcastJoinThreshold (10 MB), or a broadcast() hint |
none for the large side |
| Sort-merge join | the default for two large sides on an equi-join | both sides |
| Shuffle hash join | one side much smaller per partition; preferred by AQE when it fits | both sides, no sort |
| Broadcast nested loop join | non-equi joins with a small side | none, but O(n × m) comparisons |
| Cartesian | cross joins | everything — avoid |
# python
from pyspark.sql.functions import broadcast
joined = large_orders.join(broadcast(small_countries), "country_code")
- Trap: a full outer join cannot use a broadcast hash join at all; a left outer join can broadcast only the right side.
- Trap: the size estimate can be wrong (a filtered table looks small, a UDF output looks unknown). AQE fixes it at runtime by switching to a broadcast join once it sees real sizes.
Deep dive: ../de/spark.md §7-1.
6) Data Skew Priority 5 of 5 — Must know — expect it in almost every loop
6-1) What is data skew, and how do you spot it?
Skew is when a few keys hold most of the rows, so a few tasks do most of the work and the whole stage waits for them.
Stage 3 task durations Stage 3 shuffle read per task
task 0 12 s task 0 110 MB
task 1 11 s task 1 105 MB
task 2 13 s task 2 118 MB
task 199 850 s ◄── straggler task 199 9.6 GB ◄── one hot key
How to confirm it, in order:
- Spark UI → Stages → the slow stage → Summary Metrics: compare max to median for duration and shuffle read size. Max ≫ 75th percentile is skew.
- Rule out the alternatives: a slow node (the same executor is always slow), GC (high GC time), or spill (large spill (disk)).
- Find the hot keys:
# python
(df.groupBy("join_key").count()
.orderBy("count", ascending=False)
.show(20))
Typical culprits: NULL or empty-string keys, a default value like 0 or "unknown", a
celebrity user, a country, a bot.
6-2) How do you fix data skew?
Turn on AQE skew-join handling first, broadcast if one side is small, and salt the hot keys when neither is enough.
| Fix | When | How |
|---|---|---|
| Drop or isolate junk keys | the hot key is NULL / "unknown" |
filter it out, or process it separately and union back |
| AQE skew join | sort-merge join, Spark 3.0+ | spark.sql.adaptive.skewJoin.enabled=true (on by default with AQE) splits oversized partitions |
| Broadcast join | the other side is small | broadcast(small_df) — no shuffle, so no skewed partition |
| Two-stage (salted) aggregation | skewed groupBy with an associative aggregate |
aggregate by (key, salt), then by key |
| Salted join | large-large join with a few hot keys | random salt on the skewed side, replicate the other side across all salt values |
| Split hot keys | a handful of known hot keys | broadcast-join the hot keys, sort-merge the rest, union |
Two-stage aggregation:
# python
from pyspark.sql import functions as F
SALT_BUCKETS = 16
partial = (df.withColumn("salt", (F.rand() * SALT_BUCKETS).cast("int"))
.groupBy("user_id", "salt")
.agg(F.sum("amount").alias("partial_sum"),
F.count("*").alias("partial_cnt")))
totals = (partial.groupBy("user_id")
.agg(F.sum("partial_sum").alias("total"),
F.sum("partial_cnt").alias("cnt")))
This works only for aggregates that can be combined from parts — sum, count, min, max;
an average is sum / count computed after stage two, and countDistinct needs another approach.
Salted join — the big side gets a random salt, the small side is exploded across every salt:
# python
from pyspark.sql import functions as F
SALT_BUCKETS = 16
big_salted = big.withColumn("salt", (F.rand() * SALT_BUCKETS).cast("int"))
small_salted = small.withColumn(
"salt", F.explode(F.array([F.lit(i) for i in range(SALT_BUCKETS)])))
joined = big_salted.join(small_salted, ["join_key", "salt"]).drop("salt")
The cost is replicating the smaller side SALT_BUCKETS times — salt only the hot keys when
that side is not small. Deep dive: ../de/spark.md §7-3.
7) Caching, Persistence and Checkpointing Priority 4 of 5 — High value — a gap here costs you rounds
7-1) cache vs persist
cache() is persist() at the default storage level; persist(level) lets you choose
memory, disk, serialised or replicated. Both are lazy — nothing is stored until an action runs.
- Default level:
MEMORY_ONLYfor RDDs,MEMORY_AND_DISKfor DataFrames / Datasets (shown asMEMORY_AND_DISK_DESERin PySpark). - The block manager on each executor stores the partitions; the driver tracks which blocks live where. Cached data stays across jobs in the same application, not across applications.
unpersist()releases it. Unused cached data is evicted least-recently-used under memory pressure.- Cache when a dataset is reused by two or more actions, or in an iterative loop, and it is expensive to recompute. Do not cache something used once, or something so large it evicts everything else — re-reading Parquet is often cheaper.
7-2) What storage levels are there?
Choose along three axes — memory and / or disk, deserialised objects or serialised bytes, and one copy or two.
| Level | Where | What happens when it does not fit |
|---|---|---|
MEMORY_ONLY |
memory, deserialised objects | missing partitions are recomputed when needed |
MEMORY_AND_DISK |
memory, then disk | partitions that do not fit are written to disk and read back |
MEMORY_ONLY_SER |
memory, serialised bytes | smaller, but costs CPU to deserialise; recomputes the rest |
MEMORY_AND_DISK_SER |
memory serialised, then disk | spills the rest instead of recomputing |
DISK_ONLY |
local disk only | — |
OFF_HEAP |
serialised, outside the JVM heap | needs spark.memory.offHeap.enabled and a size |
…_2 (e.g. MEMORY_AND_DISK_2) |
two replicas on different executors | faster recovery, double the memory |
In PySpark, data is always stored serialised on the JVM side, so the _SER variants make no
difference there.
7-3) Cache / persist vs checkpoint
Persisting keeps the lineage and stores data on executors for the life of the application; checkpointing writes the data to reliable storage and cuts the lineage, so it survives executor loss and keeps the plan short.
persist / cache |
checkpoint |
|
|---|---|---|
| Stored on | executor memory / local disk | reliable storage — HDFS, S3 (sc.setCheckpointDir) |
| Lineage | kept — a lost block is recomputed | truncated — the checkpoint becomes the new source |
| Lifetime | ends with the application | files stay until you delete them |
| Cost | cheap | an extra full write; recomputes the data unless it is persisted first |
| Use for | reuse within a job | very long lineages (iterative ML, graph algorithms), streaming state |
persist(DISK_ONLY)is on local disk, not RAM — and still lost with its executor.rdd.checkpoint()runs a separate job after the next action, which recomputes the RDD — so persist it before checkpointing.DataFrame.checkpoint()is eager by default.localCheckpoint()truncates lineage while storing on executors: faster, not fault tolerant.- Structured Streaming’s
checkpointLocationis a different thing: offsets and state for recovery (see 11-3).
7-4) How do you clean up accumulated metadata?
Spark’s ContextCleaner removes shuffle files, broadcasts and cached RDDs once the driver no
longer references them; for long-running applications you help it by unpersisting and dropping
references.
spark.cleaner.ttl, the answer in older interview lists, was removed in Spark 2.0.- Call
unpersist()/destroy()explicitly when done with a cached dataset or broadcast. spark.cleaner.periodicGC.interval(30 min) forces a driver GC so weak references are found in a long-running driver.- Very long lineages (an iterative loop of thousands of steps) bloat the driver’s plan:
checkpoint()periodically, or write intermediate results to storage and read them back.
8) Memory and OOM Priority 5 of 5 — Must know — expect it in almost every loop
8-1) How is executor memory laid out?
The JVM heap holds 300 MB reserved, a user region for your objects, and a unified region that execution and storage share; outside the heap sits the memory overhead.
Executor container (YARN container / K8s pod)
├── spark.executor.memoryOverhead max(384 MB, 10% of executor memory): JVM internals,
│ thread stacks, NIO buffers, Python workers
└── spark.executor.memory (JVM heap)
├── Reserved memory 300 MB, fixed
├── User memory (1 − spark.memory.fraction) × (heap − 300 MB)
│ your data structures, UDF objects
└── Unified memory spark.memory.fraction (0.6) × (heap − 300 MB)
├── Storage cache, broadcasts; spark.memory.storageFraction (0.5)
│ of it is protected from eviction
└── Execution shuffles, joins, sorts, aggregations
- Borrowing: execution can take free storage memory and evict cached blocks above the protected part; storage can borrow free execution memory but can never evict it.
- Off-heap (
spark.memory.offHeap.enabled+.size) moves Tungsten’s buffers out of the heap, away from GC.
Worked numbers for --executor-memory 8g: unified = 0.6 × (8192 − 300) MB ≈ 4.6 GB, of which
≈ 2.3 GB is protected storage; user ≈ 3.1 GB; overhead = 819 MB, so the container asks for
≈ 8.8 GB. Deep dive: ../de/spark.md §9-1.
8-2) How do you prevent and fix out-of-memory errors?
First find out whose memory ran out — the driver’s, an executor’s heap, or the container’s overhead — because each has a different fix.
| Symptom | Usual cause | Fix |
|---|---|---|
Driver OutOfMemoryError: Java heap space |
collect() / toPandas() of a large result; broadcasting a big table; huge plans |
write to storage instead of collecting; take(n); smaller broadcasts; raise spark.driver.memory |
Total size of serialized results … is bigger than spark.driver.maxResultSize |
the same, caught early | stop collecting; raise the limit only if the result is genuinely needed |
Executor OutOfMemoryError in one or two tasks |
a skewed partition; groupByKey on a hot key |
fix the skew (section 6), more partitions |
| Executor OOM across many tasks | partitions too large; too many cores sharing one heap | raise spark.sql.shuffle.partitions; fewer cores per executor; more memory |
Container killed … exceeding memory limits / exit code 137 |
overhead, not heap — Python workers, off-heap, native buffers | raise spark.executor.memoryOverhead |
GC overhead limit exceeded, long GC times |
too many live objects | fewer cached objects, DataFrames over RDDs, smaller partitions |
The habit that prevents most of them: never pull a large dataset to the driver, keep partitions around 100–200 MB, and fix skew rather than throwing memory at it.
Further reading: Spark OOM error closeup.
8-3) What is a spill, and is it a problem?
A spill is an operator writing its in-memory buffer to local disk because execution memory filled up; it keeps the task alive but makes it much slower.
- The UI shows it per task as Spill (memory) and Spill (disk).
- Occasional spill is fine. Large spill on every task means partitions are too big — raise the partition count. Large spill on a few tasks means skew.
- An OOM happens when something cannot spill: one huge record, a shuffle-hash build side, a Python worker, or exhausted overhead.
9) Fault Tolerance and Failures Priority 4 of 5 — High value — a gap here costs you rounds
9-1) An executor or node fails — what happens?
Spark reruns the lost tasks elsewhere and recomputes lost partitions from lineage; if shuffle output was lost too, it reruns the map stage that produced it.
- Task failure — retried on another executor, up to
spark.task.maxFailures(4) times; the fourth failure fails the stage and the job. - Executor lost — its running tasks are rescheduled; its cached blocks are recomputed when next needed.
- Shuffle files lost — reducers hit a
FetchFailedException; the scheduler resubmits the parent map stage to regenerate them. The external shuffle service avoids this when only the executor, not the node, died. - A bad node —
spark.excludeOnFailure.enabledstops scheduling on executors or nodes that keep failing. - Stragglers —
spark.speculation=truelaunches a duplicate of a slow task and keeps whichever finishes first. Use it only when output writes are idempotent.
When the cause is overload (the node was killed for memory, or heartbeats timed out under
GC): retries will fail the same way, so fix the load — more partitions, fix skew, raise memory
overhead, smaller executors — rather than raising maxFailures.
9-2) What if the driver fails?
The application dies with it — executors are owned by the driver — so recovery comes from the cluster manager restarting it and the job being safe to rerun.
- YARN
clustermode retries the ApplicationMaster (spark.yarn.maxAppAttempts); Standaloneclustermode has--supervise. - A restarted batch job starts over, so its writes must be idempotent (13-2).
- A restarted streaming query resumes from its checkpoint (11-3).
10) Spark SQL, Catalyst and AQE Priority 4 of 5 — High value — a gap here costs you rounds
10-1) How does the Catalyst optimiser work?
Catalyst turns a query into a tree and rewrites it in four phases — analysis, logical optimisation, physical planning and code generation — before any data is read.
- Analysis — resolve table and column names against the catalog: an unresolved logical plan
becomes a resolved one, or fails with
AnalysisException. - Logical optimisation — rule-based rewrites: predicate pushdown, column pruning, constant folding, combining filters, simplifying expressions.
- Physical planning — generate physical plans (which join strategy, which aggregation) and choose one, using statistics and the cost model where available.
- Code generation — whole-stage codegen compiles a chain of operators into one JVM function per stage, avoiding per-row virtual calls.
Tungsten is the execution side: compact binary rows (UnsafeRow), off-heap buffers, and
that generated code. Deep dive: ../de/spark.md §4-1.
10-2) How do you read a physical plan?
Read explain() bottom-up: the scan, then filters and projections, then every Exchange
(a shuffle) and the join and aggregate strategies around it.
# python
df.explain("formatted")
| Node | What it tells you |
|---|---|
FileScan parquet … PushedFilters: […], ReadSchema: … |
pushdown and pruning worked — or did not |
Filter, Project |
row filtering and column selection |
Exchange hashpartitioning(key, 200) |
a shuffle on key into 200 partitions |
BroadcastExchange + BroadcastHashJoin |
a broadcast join |
Sort + SortMergeJoin |
a shuffle join |
HashAggregate → Exchange → HashAggregate |
partial aggregation, shuffle, final aggregation |
*(n) prefix |
operators fused by whole-stage codegen into stage n |
AdaptiveSparkPlan isFinalPlan=true |
AQE re-planned this query at runtime |
10-3) What is AQE, and how is it different from Catalyst?
Catalyst plans before running from estimates; Adaptive Query Execution re-plans during the run, at each shuffle boundary, from the real sizes the finished stages report. It is on by default since Spark 3.2.
AQE does three things:
- Coalesces shuffle partitions — merges many tiny post-shuffle partitions into fewer
right-sized ones, so
spark.sql.shuffle.partitionscan be set high safely. - Switches join strategy — turns a sort-merge join into a broadcast join when one side turns out small.
- Splits skewed partitions — breaks an oversized join partition into several tasks.
# python
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
AQE cannot help before the first shuffle (a scan reading too many small files), nor with skew
inside a groupBy — those are still yours to fix.
10-4) Predicate pushdown and column pruning
Pushdown moves a filter into the data source and pruning reads only the columns you use, so the skipped data is never read from disk.
- Parquet / ORC keep min / max statistics per row group, so a pushed filter skips whole row groups; being columnar, they read only the selected columns.
- Partition pruning skips whole directories:
WHERE dt = '2026-11-06'on a table partitioned bydtlists one folder. - Dynamic partition pruning (3.0+) applies a filter on a dimension table to a partitioned fact table at runtime.
- What breaks it: a UDF in the filter, a cast on the filtered column, or CSV / JSON sources
with no statistics. Confirm with
PushedFiltersin the plan.
11) Streaming Priority 4 of 5 — High value — a gap here costs you rounds
11-1) Spark Streaming (DStreams) vs Structured Streaming
DStreams is the legacy RDD-based streaming API; Structured Streaming treats a stream as an unbounded table, runs it through the SQL engine, and is the one to use.
| DStreams | Structured Streaming | |
|---|---|---|
| API | RDDs per batch interval | DataFrames / SQL |
| Optimiser | none | Catalyst + Tungsten |
| Event time and watermarks | manual | built in |
| End-to-end exactly-once | build it yourself | with a replayable source + idempotent or transactional sink |
| Status | legacy, no new features | current |
Execution is micro-batch by default — each trigger processes the new data as a small batch job. An experimental continuous mode reaches millisecond latency for map-like queries only.
# python
events = (spark.readStream.format("kafka")
.option("kafka.bootstrap.servers", "broker:9092")
.option("subscribe", "events")
.load())
query = (events.selectExpr("CAST(value AS STRING) AS json")
.writeStream.format("parquet")
.option("path", "s3://bucket/output/")
.option("checkpointLocation", "s3://bucket/checkpoints/events/")
.outputMode("append")
.start())
11-2) Event time, watermarks and output modes
Event time is when the event happened, processing time is when Spark saw it; a watermark says how late an event may arrive and still count, which is also what lets Spark drop old state.
# python
from pyspark.sql import functions as F
counts = (events
.withWatermark("event_time", "10 minutes")
.groupBy(F.window("event_time", "5 minutes"), "country")
.count())
- Watermark = max event time seen − 10 minutes. A window is finalised, and its state dropped, once the watermark passes its end. Data older than the watermark may be dropped — the guarantee is only that data within the threshold is never dropped.
- Without a watermark, state for a windowed aggregation or a stream–stream join grows forever.
- Deep dive — late rows, eviction per output mode, joins, multiple watermarks and the
production signals:
spark.md§12-2 to §12-6.
| Output mode | Emits | Works with |
|---|---|---|
append |
only rows that will never change | aggregations only with a watermark |
update |
rows changed in this trigger | aggregations; the sink must upsert |
complete |
the whole result table every trigger | aggregations only; state never shrinks |
11-3) Checkpoints and exactly-once
The checkpoint stores the source offsets and operator state for every micro-batch, so a restarted query resumes exactly where it stopped; exactly-once output additionally needs a replayable source and an idempotent or transactional sink.
- Source — must be replayable (Kafka, files) so a batch can be re-read after a crash.
- Checkpoint — offsets are written before a batch runs and committed after; one directory per query, on durable storage, never shared.
- Sink — the file sink and Delta / Iceberg commit atomically per batch; for
foreachBatchyou make the write idempotent yourself, for example aMERGEkeyed on the batch id or a business key. - A checkpoint is tied to the query’s shape: changing aggregations or state schema usually means a new checkpoint and a backfill.
11-4) What errors happen in Spark streaming, and how do you fix them?
Most streaming incidents are one of: the batch takes longer than its trigger, state grows without bound, the source lost data before it was read, or a restart produced duplicates.
| Symptom | Cause | Fix |
|---|---|---|
| Batch duration > trigger interval, lag keeps growing | input rate above throughput; a skewed or shuffle-heavy batch | more executors or partitions; maxOffsetsPerTrigger to cap batch size; simplify the query |
| Executors OOM after hours or days | unbounded state — no watermark, or a long dropDuplicates window |
add a watermark; RocksDB state store (spark.sql.streaming.stateStore.providerClass) |
OffsetOutOfRangeException / data loss warning |
Kafka retention deleted offsets the query had not read | longer retention; alert on lag; failOnDataLoss only as a conscious choice |
| Duplicates after a restart | a non-idempotent sink in foreachBatch |
idempotent write keyed on batch id or business key |
| Query will not restart after a code change | an incompatible change to the checkpointed state | new checkpoint location plus a backfill |
| Too many small files | a file sink with short triggers | longer triggers; compaction (OPTIMIZE) on Delta / Iceberg |
11-5) Caching in Spark Streaming (DStreams)
A DStream can be persist()ed like an RDD, which helps when the same stream is used by
several operations; network receivers (Kafka, sockets) already store received data replicated
on two nodes by default.
Structured Streaming has no per-stream cache — inside foreachBatch, persist() the batch
DataFrame if it feeds several writes, and unpersist() at the end.
12) Troubleshooting Walk-throughs Priority 5 of 5 — Must know — expect it in almost every loop
The questions that separate a Spark user from a Spark engineer are open-ended: “this job is slow — what do you do?” Answer with an order of investigation, not a list of configs.
12-1) A job got slow — where do you start?
Find the slow stage in the Spark UI, find out whether the time goes to one task or to all of them, and let that pick the fix.
- Jobs / Stages — which stage dominates the wall-clock time?
- Task summary for that stage — compare max to median duration.
- A few tasks far slower → skew (section 6) or a bad node.
- All tasks slow → too much data per task, or too much work per row.
- Per-task metrics — input size, shuffle read, spill, GC time.
- Large spill → partitions too big: raise the partition count.
- High GC time → memory pressure: fewer cached objects, more memory, fewer cores per executor.
- Huge shuffle → a join or aggregation that could be broadcast, pre-aggregated or avoided.
- SQL tab — the physical plan: missing
PushedFilters, an unexpectedSortMergeJoin, aCartesianProduct, anExchangeyou did not expect. - Executors tab — failed tasks, lost executors, one executor much slower than the rest.
- Then change one thing and measure again.
Symptom in the UI Most likely cause → fix
max task time ≫ median skew → AQE skew join, broadcast, salting
every task spills partitions too big → more partitions
thousands of tasks of a few ms partitions too small / small files → coalesce, compact
one stage reads far more than it outputs no pushdown → filter early, fix the predicate
job fine yesterday, slow today input grew or a new hot key → check sizes and key counts
12-2) One task takes 850 seconds and the rest take ten
That is a straggler — compare its shuffle read size to the others to tell skew from a slow machine.
- Its shuffle read is much larger → a hot key. Find it with a
groupBy(key).count(), then apply section 6. - Its input is normal but it ran on a slow or busy executor → a node problem; speculation or excluding the node helps.
- Its spill or GC time is huge → the partition is too big for the memory per core.
- It ran fine on retry → transient (a noisy neighbour, a slow S3 read); consider speculation.
12-3) The output has 50,000 tiny files
Too many writer tasks each wrote a few MB; size the output deliberately before the write, and compact what is already there.
| Layout | 10 GB as | Effect |
|---|---|---|
| A | 10,000 files × 1 MB | slow listing, high per-file metadata and API cost, one task per file on read |
| B | 100 files × 100 MB | fast to list and read |
- Before the write:
repartition(n)orrepartition("dt")so each output partition is around 128 MB–1 GB;coalesce(n)when reducing a little after a filter. - Cap file size:
spark.sql.files.maxRecordsPerFile. - Let AQE coalesce the last shuffle (
spark.sql.adaptive.advisoryPartitionSizeInBytes). - Compact afterwards:
OPTIMIZEon Delta,rewrite_data_fileson Iceberg, or a periodic compaction job on plain Parquet. - Do not
partitionBya high-cardinality column — one directory per user ID is a small-files factory; cluster or bucket by it instead.
12-4) Which configurations are worth knowing by name?
A handful of settings explain most tuning conversations; know what each controls, and say you look at the Spark UI before changing any of them.
| Configuration | Default | Controls |
|---|---|---|
spark.executor.memory / .cores / .instances |
1g / 1 (YARN) / 2 | executor heap, task slots per executor, executor count |
spark.executor.memoryOverhead |
max(384 MB, 10%) | off-heap headroom — raise for PySpark |
spark.driver.memory |
1g | driver heap — raise only for genuinely large plans or results |
spark.sql.shuffle.partitions |
200 | partitions after a DataFrame shuffle |
spark.default.parallelism |
total cores | partitions for RDD shuffles |
spark.sql.adaptive.enabled |
true (3.2+) | AQE |
spark.sql.autoBroadcastJoinThreshold |
10 MB | the automatic broadcast-join size limit |
spark.sql.files.maxPartitionBytes |
128 MB | input split size for file scans |
spark.memory.fraction |
0.6 | heap share for execution + storage |
spark.dynamicAllocation.enabled |
false | scale executors with the workload |
spark.serializer |
Java | set to KryoSerializer for RDD-heavy jobs |
Rules of thumb: 4–5 cores per executor; shuffle partitions ≈ shuffle data ÷ 128–200 MB, at least
2–3 × total cores, then let AQE coalesce. Deep dive: ../de/spark.md §13-2.
13) Pipeline Design Prompts Priority 4 of 5 — High value — a gap here costs you rounds
Design questions test whether you can place Spark in a system. Open with the shape of the answer, then go stage by stage.
13-1) Design a Kafka → Spark → lakehouse → warehouse pipeline
Land raw events once, refine them in layers with Spark, and serve aggregates to BI — every layer replayable from the one below it.
Requirements to restate: ~1 TB of events a day, near-real-time dashboards, the ability to reprocess history, no data loss on failure, data-quality checks.
Event producers
│
▼
Kafka ─────────────────────────────┐
│ Structured Streaming │ (retention = the replay window)
▼ │
Bronze: raw events, append-only │ Delta / Iceberg on S3 / GCS, partitioned by ingest date
│ batch or streaming Spark
▼
Silver: parsed, typed, deduplicated, quality-checked
│ batch Spark
▼
Gold: business aggregates (daily revenue, sessions)
│
▼
BigQuery / Redshift / Snowflake ──► BI dashboards
| Layer | Holds | Spark’s job |
|---|---|---|
| Bronze | raw data exactly as received | append from Kafka with a checkpoint; keep the payload untouched |
| Silver | cleaned, conformed, deduplicated | parse with an explicit schema, dedupe on event_id, quarantine bad rows |
| Gold | business-level aggregates | joins and aggregations; publish to the warehouse |
Points to raise without being asked:
| Concern | Answer |
|---|---|
| Reliability | checkpoints on every streaming query; Kafka retention long enough to replay an outage |
| Duplicates | Kafka is at-least-once: dedupe in Silver (13-3), idempotent sinks |
| Reprocessing | Bronze is the replay source; Silver and Gold are rebuilt from it by date |
| Data quality | schema, null, range and uniqueness checks before a layer is published |
| Small files | streaming writes compacted by a scheduled OPTIMIZE |
| Cost | autoscaling or spot executors for batch; size streaming clusters for peak, not average |
| Monitoring | input rate vs processing rate, batch duration, consumer lag, row-count checks per layer |
13-2) Design an idempotent, re-runnable daily batch
Make every run a pure function of its logical date: read exactly one input partition, and replace exactly one output partition atomically — so running it twice gives the same result as running it once.
Job: daily_transactions(run_date = 2026-11-06)
input : s3://raw/transactions/dt=2026-11-06/
output: s3://gold/transactions/dt=2026-11-06/ (replaced, never appended to)
- Parameterise by logical date from the orchestrator — never
current_date()inside the job. - Read only that date’s partition, with an explicit schema.
- Deterministic transformations — no
rand()or wall-clock time in output values. - Overwrite, do not append: dynamic partition overwrite
(
spark.sql.sources.partitionOverwriteMode=dynamic), DeltareplaceWhere, or aMERGEon a business key. - Atomic publish — a table format’s commit, or write to staging and swap.
- Record the run — run ID, input version, row counts — so a backfill is auditable.
- Backfill by rerunning the same job over a date range; nothing else changes.
# python
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
(daily_df
.write
.mode("overwrite")
.partitionBy("dt")
.parquet("s3://gold/transactions/")) # replaces only the dt partitions present in daily_df
13-3) Deduplicate a stream
Deduplicate on a unique event ID inside a watermark, so the state Spark keeps for “IDs seen” stays bounded — and make the sink idempotent for anything that slips past.
# python
deduped = (events
.withWatermark("event_time", "10 minutes")
.dropDuplicates(["event_id", "event_time"]))
- Including the event-time column lets Spark drop state once the watermark passes. Spark 3.5
adds
dropDuplicatesWithinWatermark(["event_id"]), which does not need the timestamp to match. - A duplicate arriving after its state was dropped is not caught — so the sink still needs a
MERGEonevent_id(or a primary key) as the final guard.
13-4) Managed Spark on AWS and GCP
Know which managed service runs Spark and what surrounds it, so a design answer names real components.
| Cloud | Service | Role |
|---|---|---|
| AWS | EMR (on EC2, EKS or Serverless) | managed Spark / Hadoop clusters |
| AWS | Glue | serverless Spark ETL plus the Glue Data Catalog |
| AWS | S3 · MSK · Redshift | storage · managed Kafka · warehouse |
| GCP | Dataproc (clusters or Serverless) | managed Spark / Hadoop |
| GCP | Dataflow | managed Apache Beam — the alternative to Spark for pipelines |
| GCP | GCS · Pub/Sub · BigQuery | storage · messaging · warehouse |
| Any | Databricks | managed Spark with Delta Lake |
Cost levers to mention: ephemeral clusters per job, autoscaling, spot / preemptible executors for batch, right-sized partitions so the cluster is not idle waiting on stragglers.
14) Study Plan and Question Bank Priority 3 of 5 — Worth knowing — usually a variant of a must-know pattern
14-1) What to learn first
Architecture, the job → stage → task model, shuffles, skew and joins come up in nearly every Spark interview; master those before tuning knobs or table formats.
| Priority | Topic | Here |
|---|---|---|
| P0 | Architecture, spark-submit, driver vs executor |
2-1, 2-2 |
| P0 | Job / stage / task, narrow vs wide | 3-1, 3-3 |
| P0 | Shuffle and partitions | 5-1, 3-4 |
| P0 | Data skew | 6-1, 6-2 |
| P0 | Join strategies | 5-5 |
| P1 | Catalyst and AQE | 10-1, 10-3 |
| P1 | Memory model and OOM | 8-1, 8-2 |
| P1 | Spark UI troubleshooting | 12-1 |
| P1 | Structured Streaming, watermarks, checkpoints | 11-2, 11-3 |
| P2 | Table formats and pipeline design | 13-1, 13-2 |
| P2 | Managed Spark in the cloud | 13-4 |
14-2) The question bank, by difficulty
Answer each out loud in under a minute, then check against the linked section.
| # | Question | Level | Answer |
|---|---|---|---|
| 1 | What is Spark, and how does it differ from MapReduce? | basic | 1-1, 1-2 |
| 2 | What are the driver and the executors? | basic | 2-1 |
| 3 | Transformation vs action — why is Spark lazy? | basic | 4-3 |
| 4 | What is an RDD? RDD vs DataFrame vs Dataset? | basic | 4-1, 4-2 |
| 5 | cache vs persist; which storage levels exist? |
basic | 7-1, 7-2 |
| 6 | Job, stage, task — how are stages split? | intermediate | 3-1, 3-2 |
| 7 | Narrow vs wide dependencies? | intermediate | 3-3 |
| 8 | Is a task a process? Do more executors mean more tasks? | intermediate | 3-1 |
| 9 | What triggers a shuffle, and why is it expensive? | intermediate | 5-1, 5-2 |
| 10 | repartition vs coalesce? |
intermediate | 3-5 |
| 11 | reduceByKey vs groupByKey? |
intermediate | 4-5 |
| 12 | Broadcast variables and accumulators — any caveat? | intermediate | 5-4 |
| 13 | Cache vs checkpoint? | intermediate | 7-3 |
| 14 | What happens when an executor dies? | intermediate | 9-1 |
| 15 | Event time vs processing time; what is a watermark? | intermediate | 11-2 |
| 16 | How do you detect and fix data skew? | advanced | 6-1, 6-2 |
| 17 | Broadcast join vs sort-merge join — how would you optimise a 10 TB join? | advanced | 5-5, 6-2 |
| 18 | How does Catalyst work? What does AQE add? | advanced | 10-1, 10-3 |
| 19 | Read this physical plan — where are the shuffles? | advanced | 10-2 |
| 20 | Driver OOM vs executor OOM vs container killed? | advanced | 8-2 |
| 21 | A job is slow — walk me through debugging it. | advanced | 12-1, 12-2 |
| 22 | How do you get exactly-once output from Structured Streaming? | advanced | 11-3 |
| 23 | A streaming job’s lag keeps growing — what do you check? | advanced | 11-4 |
| 24 | How do you deal with small files? | advanced | 12-3 |
| 25 | Design a TB-scale pipeline from Kafka to the warehouse. | design | 13-1 |
| 26 | Design an idempotent daily batch job with backfills. | design | 13-2 |
| 27 | Spark or Flink for this use case? | design | 1-7 |
14-3) Self-check before the interview
You are ready when you can do each of these without notes.
- [ ] Draw the driver, cluster manager and executors, and narrate
spark-submitend to end. - [ ] Given a short DataFrame program, say how many jobs and stages it runs, and where the shuffles are.
- [ ] Say why a task is not a process, and how many tasks a given cluster runs at once.
- [ ] Explain why
reduceByKeybeatsgroupByKey, and why DataFrames make the question moot. - [ ] Name every join strategy and when Spark picks each.
- [ ] Diagnose skew from a Spark UI task table, and write a salted aggregation from memory.
- [ ] Lay out executor memory, and say which OOM a given error message points to.
- [ ] Read an
explain()output and point at pushdown,Exchangeand the join strategy. - [ ] Explain watermarks, output modes and what end-to-end exactly-once needs.
- [ ] Design a re-runnable daily batch, and say how you would backfill a month.
- [ ] Say when you would choose Flink, a warehouse or DuckDB instead of Spark.
References
- Spark documentation — RDD programming guide
- Spark documentation — SQL performance tuning
- Spark documentation — Structured Streaming programming guide
- Spark documentation — tuning (memory, serialisation)
- Cluster concepts: jobs, stages and tasks
- Stanford CS246 — MapReduce and Spark
- Cache vs checkpoint (Stack Overflow)
- spark-submit options
- Wide vs narrow dependencies