01 · What it is
A small society of processes
A Spark application is one program — your notebook or job — that runs as three kinds of process working together:
- Driver — the brain. Runs your code, holds the
SparkSession, turns DataFrame code into a plan, and schedules the work. There is exactly one per application. - Executors — the muscle. JVM processes on the worker nodes that run tasks and hold cached data. There are N of them.
- Cluster manager — the landlord. Allocates the worker machines (Standalone / YARN / Kubernetes; on Databricks the platform manages it). It allocates machines — it does not schedule your tasks.
Two more choices frame the picture: where you launch from — the edge / gateway node — and where the driver runs — the deployment mode (client vs cluster). The deep dive unpacks each; for now just hold the shape.
The one driver plans and schedules the work; the N executors run the tasks and hold the data.
filter, select, withColumn stay in one stage; no data crosses the network.
groupBy, join, distinct redistribute rows across the network — a new, costly stage.
02 · Why it matters
You can't tune what you can't see
- Every performance problem is a problem with this picture — too much on the driver, the wrong partition count, or an expensive shuffle between stages. The rest of the track is refinements of this one model.
- The shuffle is the villain of the whole track. It is the single most expensive thing Spark does, and Lessons 02–11 are about avoiding, repairing, or surviving it. The deep dive is where we take it apart.
- Interviewers start here. "Walk me through what happens when you call
df.count()" separates people who use Spark from people who understand it — the answer is the job → stage → task chain below.
Figure A · lazy vs action
Which line fires the job?
Click each line to classify it. Transformations are lazy — they only extend the plan; the action is the one line that submits a job. Watch the counter: it ticks only when you hit an action. (This isolates one idea — laziness. Where the work then runs is Figure B.)
Tap a line to reveal whether it runs anything. Find the single line that triggers execution.
Simplified illustration — show() / take() also fire jobs; noop runs the full plan but discards output.
03 · How it works — deep dive
From one line of code to a fleet of tasks
The whole model is a chain: your code builds a plan, an action turns it into a job, shuffle boundaries cut it into stages, and each stage fans out into tasks. Each idea below is explained here once — later sections show it, verify it, and help you decide, but don't re-explain it.
3.1 · The three roles
analogy A restaurant kitchen — the driver is the head chef calling orders and plating the dish; the executors are the line cooks at their stations; the cluster manager is the owner deciding how many cooks you get.
- Driver. Runs user code, builds the logical → physical plan via Catalyst, cuts it into jobs/stages/tasks, schedules them, and collects results. It also builds the small side of a broadcast join. A single point — overload it and the whole app sinks (Lesson 03).
- Executors. Long-lived JVMs with fixed cores (task slots) and heap (Lesson 04). They run tasks, cache blocks, and write/read shuffle files. Parallelism is capped by partitions, not by raw core count.
- Cluster manager. Only allocates machines; it does not schedule tasks — the driver does. On Databricks this is fully managed.
use case A stage with 800 partitions on executors with 4 cores each needs 200 executor-cores to finish in one wave; fewer cores means more waves means longer wall-clock.
3.2 · Edge node & deployment mode — where the driver runs
- Edge / gateway node. A machine that can submit to the cluster and talk to the cluster manager, but is not in the worker pool. It holds the Spark client libraries and cluster config — the bridge from your laptop/CI host to the cluster.
- Client mode. Driver runs on the submitting / edge node. YARN: "the driver runs in the client process, and the application master is only used for requesting resources from YARN." If the client dies, the app dies. Best for interactive notebooks / REPL.
- Cluster mode. Driver runs inside the cluster. YARN: "the Spark driver runs inside an application master process… and the client can go away after initiating the application." Best for production fire-and-forget batch.
- On Databricks. The notebook front-end plays the edge-node role and the driver runs on the cluster's driver node. On a single-node cluster that node is both master and worker.
3.3 · Lazy evaluation: transformations vs actions
- Lazy. Spark records what you want but runs nothing until you ask for a result — proven live in Figure A above.
- Transformations (
select,filter,withColumn,join,groupBy) return a new DataFrame and run nothing — they extend the plan. - Actions (
count,collect,show,write,take) force execution. Each action triggers exactly one job. - Why it's good. Seeing the whole chain lets Catalyst fuse ops, push filters to the scan, and prune columns — impossible if each line ran eagerly.
3.4 · Narrow vs wide dependencies — and the shuffle
- Narrow. Each input partition feeds one output partition (
map,filter,select,union). No data moves — Spark pipelines them into one stage. - Wide. An output partition depends on many inputs, so rows are redistributed across the network — a shuffle (
groupBy, non-broadcastjoin,distinct,repartition). - The shuffle, mechanically. (1) The map side partitions its output by key and writes shuffle files to local disk; (2) a stage barrier waits for all map tasks; (3) the reduce side fetches its slices over the network. Disk + network + serialize + a wait — the most expensive thing Spark does.
spark.sql.shuffle.partitions(default 200 in OSS;autoon Databricks) sets the post-shuffle count.
use case events.groupBy("user_id").count() shuffles all events so each user's rows land together — a cluster-killer when one user holds 90% of rows (skew, Lesson 08).
3.5 · The execution hierarchy: job → stage → task
This is the canonical enumeration — the comparison table and gotchas later point back here rather than re-listing it. Read it top-down: each level unfolds into the next.
| Level | Split rule | Concrete example |
|---|---|---|
| Action → Job | Each action submits exactly one job. | 1 count() ⇒ 1 job |
| Job → Stages | A new stage is cut at every shuffle (wide) boundary; narrow ops fuse into the same stage. | 1 shuffle ⇒ 2 stages |
| Stage → Tasks | Each stage runs one task per partition. | 200 partitions ⇒ 200 tasks |
| Tasks → cores | Tasks run on cores in waves (total tasks ÷ total cores). | 200 tasks ÷ 50 cores ⇒ 4 waves |
spark.sql.shuffle.partitions only sets the count after a shuffle. A plain scan's partition count comes from the input files; change that with repartition / coalesce, not this config. Conflating the two is the most common early mistake.
04 · The code — and how to verify it
Four moves, each with its proof
Every technique below is paired with how to prove it worked — the .explain() plan node or the Spark-UI signal. Apply, then verify; never assume.
Exchange is where the cost lives; a lazy narrow transformation is nearly free.
Prove lazy evaluation (transformations build, actions fire)
python# None of these run anything — they only extend the logical plan (LAZY).
df = spark.range(0, 1_000_000)
df2 = df.filter("id % 2 = 0") # transformation — no job
df3 = df2.withColumn("bucket", df2.id % 10) # transformation — no job
# VERIFY: Spark UI > Jobs tab is EMPTY right now. Nothing has executed.
count = df3.count() # ACTION — fires exactly one job
# VERIFY: Spark UI > Jobs tab now shows ONE new job for count().
See the plan & stage boundaries (narrow vs wide)
pythonfrom pyspark.sql.functions import col # used by the col("id") snippets below
# Narrow-only: filter + withColumn fuse into ONE stage, no shuffle.
narrow = spark.range(1_000_000).filter("id > 10").withColumn("x", col("id") * 2)
narrow.explain(mode="formatted")
# Project +- Filter +- Range <- no Exchange = no shuffle = single stage
# Wide: groupBy forces a shuffle -> a stage boundary appears.
wide = spark.range(1_000_000).groupBy(col("id") % 100).count()
wide.explain(mode="formatted")
# HashAggregate
# +- Exchange hashpartitioning(...) <- Exchange = the shuffle = a NEW stage (costly)
# +- HashAggregate +- Range
Same idea in Spark SQL — the GROUP BY introduces the Exchange:
sqlEXPLAIN FORMATTED
SELECT id % 100 AS g, count(*) FROM range(1000000) GROUP BY id % 100;
-- Look for the `Exchange` node — that is the shuffle / stage boundary.
Inspect & control partitions (one task per partition)
pythondf = spark.range(1_000_000)
print(df.rdd.getNumPartitions()) # in-memory partitions => tasks per stage
shuffled = df.groupBy(col("id") % 7).count()
print(shuffled.rdd.getNumPartitions()) # -> 200 (post-shuffle count, OSS default)
# Demo only — set it small and watch tasks change (AQE may coalesce it, Lesson 05).
spark.conf.set("spark.sql.shuffle.partitions", 8)
# VERIFY: Spark UI > Stages tab — post-shuffle stage now shows 8 tasks, not 200.
Time a plan without pulling data to the driver
pythonimport time
# The "noop" sink runs the FULL job but writes nothing — time a plan WITHOUT
# a driver-OOM-risky collect() (Lesson 03).
t0 = time.time()
wide.write.format("noop").mode("overwrite").save()
print(round(time.time() - t0, 2), "s")
# VERIFY: Spark UI > SQL tab shows the completed query; Stages tab shows the
# Exchange's Shuffle Read/Write bytes — the cost of the wide op.
Figure B · the shuffle, step by step
See it run across the cluster
Everything above comes together here. This is events.groupBy("key").count() on a tiny cluster — 1 driver + 3 executors, with 9 records tagged A, B, or C scattered across the input partitions. Step through it with Prev / Play / Next and watch the shuffle physically move records across the network so every key lands on one executor — the wide-dependency boundary at the heart of this track.
Driver builds the plan; executors do the work. Use Prev / Play / Next to walk the stages.
Driver plan + schedule
Executor 1 2 cores
Executor 2 2 cores
Executor 3 2 cores
Simplified illustration — real shuffles use spark.sql.shuffle.partitions (200 in OSS; auto on Databricks) post-shuffle partitions, not one per key.
05 · Summary table
Narrow vs wide — and where the driver runs
Read the first table by the operation you're about to call — it answers "does this shuffle?" at a glance. (The why is §3.4; this is the which.)
| Dimension | Narrow dependency | Wide dependency (shuffle) |
|---|---|---|
| Examples | map, filter, select, withColumn, union | groupBy, join (non-broadcast), distinct, repartition |
| Data movement | None — same executor | Rows redistributed across the network |
| Stage | Fused into the same stage | Forces a new stage (a barrier) |
| Plan node | no Exchange | Exchange (one per shuffle) |
| Cost | Cheap (CPU only) | Expensive — disk + network + serialize + wait |
| Deployment mode | Where the driver runs | Client can disconnect? | Best for |
|---|---|---|---|
| Client | Submitting / edge node (Databricks: driver node) | No — client death kills the app | Interactive notebooks, REPL |
| Cluster | Inside the cluster (YARN ApplicationMaster) | Yes — fire-and-forget | Production batch / scheduled jobs |
06 · Edge cases & limitations
Where it gets subtle
New nuance an interviewer probes — not a re-run of the hierarchy above:
- A "no-op"-looking action still runs a job.
show()andtake(n)trigger jobs (sometimes partial); evennoopruns the full plan and only discards the output. - Too few vs too many partitions. Too few → idle cores and huge tasks that spill/OOM; too many tiny partitions → scheduling overhead dominates. AQE auto-coalesces the over-partitioned case (Lesson 05).
- Two meanings of "partition." Here it's an in-memory Spark partition (a chunk → one task). On-disk Hive/Delta partitions (directories) are a different thing (Lesson 07).
- Single-node Databricks cluster. The driver node is also the only worker, so driver and executor share one machine — fine for dev, misleading for perf intuition.
Hard limits worth stating plainly:
- The driver is a single point of failure and a bottleneck — it can't scale out; protect it (Lesson 03).
- A shuffle cannot be made free, only avoided (broadcast, bucketing) or reduced (AQE coalesce). No flag "turns off" a required shuffle.
- Lazy evaluation hides cost — a long chain looks instant until the action runs all of it at once. Reason from the plan, not the line count.
shuffle.partitionsis 200 in OSS Spark; on Databricks it can beauto. State which you mean.
07 · Common mistakes
Gotchas — the mistake and its one-line fix
| Mistake | Why it hurts | Better move |
|---|---|---|
| Thinking transformations run | Only actions execute; a "slow filter" is really the action that finally ran the whole chain. | Time the action and read the plan, not the transformation. |
| Assuming more executors = faster | Parallelism is capped by the partition count; 1000 cores can't help a stage with 8 partitions. | Match partitions to cores before adding executors. |
Ignoring the Exchange | Every Exchange is a shuffle you're paying for. | Ask which Exchange nodes are present and whether one can be removed. |
collect() "just to look" at a big DataFrame | Pulls every row to the driver and risks driver OOM (Lesson 03). | Use take(n), show(), or a noop write. |
| Mixing up the cluster manager and the driver | The manager only allocates machines — it does not schedule tasks. | Remember the driver schedules tasks; the manager just hands out machines. |
08 · References
Sources
- Apache Spark — Cluster Mode Overview (driver, executors, cluster manager, jobs/stages/tasks)
- Apache Spark — Running on YARN (client vs cluster deploy modes)
- Apache Spark — RDD Programming Guide (transformations vs actions, narrow/wide, shuffle)
- Apache Spark — Configuration (spark.sql.shuffle.partitions)
- Apache Spark — SQL Performance Tuning (shuffle partitions, AQE)
- Azure Databricks — Compute configuration (the driver node)