Education › Data › Stage 3: Scale & streaming

Distributed processing with Spark

DataFrames, lazy execution, shuffles, joins, skew, and reading the Spark UI.

Intermediate ~40 min read Module 10 of 16

When the data no longer fits on one machine, or the transformation needs code rather than SQL, the work is split across a cluster. Apache Spark is the engine most teams reach for: a DataFrame API in Python, Scala or SQL, lazy execution with a query optimiser, and a runtime that distributes the work across executors. It is also where the most expensive mistakes in data engineering happen, because a shuffle or a skewed join that is invisible in a test on a thousand rows becomes hours and dollars on a billion. This module covers the execution model you must understand to use Spark well, the DataFrame patterns that matter, shuffles and skew, reading the Spark UI, and the decision of when Spark is the wrong tool.

After this module you can
  • Explain Spark's execution model: driver, executors, lazy plans, jobs, stages, tasks and partitions
  • Write DataFrame transformations that let the optimiser do its work
  • Identify and reduce shuffles; recognise and fix data skew
  • Read the Spark UI and the physical plan to find the slow stage
  • Choose between Spark, a warehouse and single-node engines for a given job

The execution model

A Spark application has a driver (your program: builds the plan, coordinates) and executors (worker processes that hold data partitions in memory and run tasks). Nothing runs until an action (write, count, collect) triggers a job. The job is split into stages at every point where data must be redistributed across executors — a shuffle — and each stage runs one task per partition of its input. Partitions are the unit of parallelism: two hundred partitions means up to two hundred tasks running concurrently, one task per core.

A complete Spark job: read Parquet, transform lazily, write. The three shuffle points are marked; everything else runs partition-local.
python
from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("daily_revenue").getOrCreate()

orders = spark.read.parquet("s3://acme-lake/silver/orders/")          # lazy: nothing read yet
customers = spark.read.parquet("s3://acme-lake/silver/customers/")

result = (
    orders
    .filter(F.col("ordered_at") >= "2026-03-01")                        # partition-local; pushed to the scan
    .filter(F.col("status") == "paid")
    .join(customers.select("customer_id", "country"), "customer_id")     # shuffle 1 (or broadcast, see below)
    .groupBy(F.to_date("ordered_at").alias("day"), "country")            # shuffle 2: group by key
    .agg(F.sum("amount").alias("revenue"), F.countDistinct("order_id").alias("orders"))
    .repartition(1)                                                      # shuffle 3: single output file (small result)
)

result.write.mode("overwrite").partitionBy("day").parquet("s3://acme-lake/gold/daily_revenue/")  # action: runs the job
STAGE 1: PARTITION-LOCALSTAGE 2tasks per partitionMarch rows onlycopied to executorsper partitionsmall partialsnetworkDriverplans, schedulesFileScan orderspushed filtersBroadcastcustomers, 20 MBBroadcast joinno shuffle of ordersPartial aggsum per partitionExchangeshuffle by group keyFinal aggsum of partialsWrite Parquetaction
One Spark job, two stages: everything before the shuffle runs partition-local on each executor (scan with pushed filters, broadcast join, partial aggregation); the Exchange redistributes rows by group key across the network; the final aggregation and write run in the second stage. The broadcast keeps the large table out of the shuffle entirely.

The optimiser (Catalyst) rewrites the plan before execution: it pushes filters down to the scan so only the March partitions are read, prunes unused columns, and picks join strategies. Adaptive query execution (on by default in current versions) re-plans between stages using observed sizes: coalescing tiny partitions, switching join strategies, splitting skewed partitions. Your job is to write transformations the optimiser can see through — DataFrame operations and SQL — and to avoid Python UDFs and collect() that turn the plan opaque or pull data to the driver.

Watch out

df.collect() and df.toPandas() bring the whole result to the driver. On a big DataFrame that is an out-of-memory crash. Use them only on aggregates you know are small; write results to storage otherwise.

Shuffles: the cost that matters

A shuffle writes every partition's data to local disk grouped by key, then every executor fetches its share over the network. It is the most expensive thing Spark does and it happens on groupBy, join, distinct, repartition, window functions with partitionBy, and orderBy. Reducing shuffles, or the data that flows through them, is most of Spark tuning. Filter and project before the shuffle so less data moves; aggregate before joining when possible; and use a broadcast join when one side is small (under a few hundred MB): the small table is copied to every executor and the join runs partition-local with no shuffle of the big side.

Forcing a broadcast join for a small dimension and confirming the plan shows no shuffle on the large side.
python
from pyspark.sql import functions as F

countries = spark.read.parquet("s3://acme-lake/silver/countries/")          # a few thousand rows

joined = orders.join(F.broadcast(countries), "country_code")
joined.explain(mode="formatted")
# look for: BroadcastHashJoin ... and no Exchange (shuffle) on the orders side

# the threshold below which Spark broadcasts automatically (default 10 MB); raise for medium dimensions
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", str(256 * 1024 * 1024))

The number of shuffle partitions (spark.sql.shuffle.partitions, default 200) sets how many tasks the post-shuffle stage has. Too few means huge tasks and spills; too many means thousands of tiny tasks and files. Adaptive execution coalesces small partitions automatically, so the modern advice is to set the value high enough for the largest shuffle and let AQE shrink it. Output file counts follow from partition counts; a final coalesce(n) (no shuffle) or repartition(n) (shuffle, balanced) controls how many files land.

Skew

Data skew is when one key holds far more rows than the others: a NULL customer id, a default account, one giant tenant. After a shuffle on that key, one task gets most of the data and the whole stage waits for it; the Spark UI shows a stage where the median task took seconds and the maximum took an hour. AQE's skew join optimisation splits oversized partitions for sort-merge joins, which handles many cases automatically. When it does not, the fixes are manual: handle the hot key separately (filter it out and union its result), salt the key (append a random suffix on the big side and explode the small side to match, so the hot key spreads over N tasks), or broadcast the small side so no shuffle happens at all.

Salting a skewed join key: the big side gets a random suffix, the small side is replicated N times, the join spreads the hot key over N tasks.
python
from pyspark.sql import functions as F

N = 16
big_salted = events.withColumn("salt", (F.rand() * N).cast("int"))
small_replicated = users.crossJoin(spark.range(N).withColumnRenamed("id", "salt"))

joined = big_salted.join(
    small_replicated,
    on=["user_id", "salt"],
).drop("salt")

# confirm AQE settings that help before resorting to salting
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")

Find skew before it finds you: df.groupBy(key).count().orderBy(F.desc('count')).show(10) on a sample tells you whether one key dominates. Skew on groupBy keys is handled by two-phase aggregation (partial aggregates before the shuffle, which Spark does automatically for sums and counts but not for collect_list or countDistinct over a hot key).

Reading the Spark UI and the plan

The Spark UI (port 4040 while running, the history server afterwards) is where every performance question is answered. Jobs tab: which action took the time. Stages tab: for the slow job, which stage; within it, the task duration distribution (skew shows as a long tail), shuffle read and write sizes (how much data moved), and spill (memory pressure). SQL tab: the physical plan with per-operator rows and time, where you see the scan's pushed filters, the join strategy and every Exchange. Executors tab: memory use, GC time, failed tasks — an executor spending half its time in garbage collection needs more memory or fewer cached DataFrames.

A physical plan excerpt with the things to look for annotated. Filters pushed into the scan, a broadcast join, and one Exchange for the aggregation.
text
== Physical Plan ==
AdaptiveSparkPlan
+- HashAggregate(keys=[day, country], functions=[sum(amount), count(distinct order_id)])
   +- Exchange hashpartitioning(day, country, 200)          <- the one shuffle; check its size in the SQL tab
      +- HashAggregate(keys=[day, country], functions=[partial_sum(amount), ...])   <- partial agg before shuffle
         +- Project [day, country, amount, order_id]
            +- BroadcastHashJoin [customer_id], [customer_id], Inner    <- small side broadcast: no shuffle of orders
               :- Filter (status = paid)
               :  +- FileScan parquet orders[...]
               :       PartitionFilters: [ordered_at >= 2026-03-01]      <- partition pruning is working
               :       PushedFilters: [EqualTo(status,paid)]             <- predicate pushed to the reader
               +- BroadcastExchange
                  +- FileScan parquet customers[customer_id, country]    <- column pruning: 2 of 40 columns
  • No PartitionFilters on a partitioned table: the filter is not prunable; rewrite it.
  • SortMergeJoin with a tiny side: broadcast it.
  • An Exchange right after another Exchange on the same key: a repartition you did not need.
  • Stage with max task time far above median: skew.
  • Spill (memory and disk) on a stage: more partitions, more executor memory, or less cached data.
  • A BatchEvalPython operator: a Python UDF; replace with built-in functions or a pandas UDF.

When Spark is the wrong tool

Spark's overhead is real: cluster start-up, scheduling, shuffles, and the operational weight of managing it. For data that fits on one large machine — which today means hundreds of GB — single-node engines (DuckDB, polars) are often faster end to end and far simpler. For SQL-expressible transformations over data already in the warehouse, the warehouse's engine is usually cheaper and needs no cluster. Spark earns its place when data is genuinely large, when the logic needs code (complex parsing, ML feature pipelines, custom algorithms), when it must read and write many formats and systems, or when streaming and batch share one codebase.

SituationReach for
Under a few hundred GB, transformation in SQL or DataFrame opsDuckDB or polars on one machine
Data already in the warehouse, SQL logicThe warehouse via dbt
Multi-TB joins, custom code, ML features, many sourcesSpark (managed: Databricks, EMR, Dataproc, Synapse)
Sub-second streaming with complex event timeFlink (Spark Structured Streaming for micro-batch needs)
Tip

Develop Spark jobs locally on a sample with local[*] and the same code; run tests with a tiny SparkSession in pytest. Most logic bugs are found on a thousand rows in seconds, not on a billion in an hour.

Hands-on practice

See a shuffle, a skew and a broadcast

  1. Install PySpark locally (pip install pyspark) and generate 20 million event rows with a user_id where 20% of rows share one hot id, plus a small users table. Write both as Parquet.
  2. Run the daily revenue job from the lesson on the events with explain(mode='formatted'). Identify every Exchange and the join strategy.
  3. Open the Spark UI on port 4040 during the run. In the Stages tab find the join stage and record median versus max task duration; the hot key should show as a long tail.
  4. Force a broadcast of the users table and rerun. Confirm the plan shows BroadcastHashJoin and the stage count dropped.
  5. Disable AQE, rerun the sort-merge join to see the skew in full, then re-enable AQE with skew join and compare stage times. Then implement salting and compare again.
  6. Add a Python UDF for a trivial transformation and find BatchEvalPython in the plan; time it against the equivalent built-in function.
  7. Run the same aggregation in DuckDB on the same Parquet files and compare wall-clock time on your laptop. Write down at what data size you would expect Spark to win.
Cheat sheet

Distributed processing with Spark — at a glance

Main things to focus on

  • Driver plans, executors run tasks; one task per partition per core; jobs split into stages at shuffles
  • Lazy plans let the optimiser push filters and prune columns; UDFs and collect() defeat it
  • Shuffles are the cost: filter and project first, aggregate before join, broadcast small sides
  • Skew: one hot key, one slow task; AQE skew join, separate the hot key, or salt
  • Spark UI: Jobs → Stages (task distribution, shuffle size, spill) → SQL (plan with Exchanges and pushed filters)
  • Use Spark for genuinely large data or code-heavy logic; DuckDB/polars or the warehouse otherwise

Execution model

action (write/count/collect) -> job -> stages -> tasksNothing runs until an action
stage boundary = shuffle (Exchange)groupBy, join, distinct, repartition, window, orderBy
partitions = parallelism; ~2-4 per coreToo few: big tasks; too many: overhead
spark.sql.shuffle.partitions (default 200) + AQE coalesceSet high, let AQE shrink
df.explain(mode='formatted')See the physical plan before running

DataFrame patterns

filter/select earlyPushdown and pruning; less data through shuffles
F.broadcast(small_df) in joinNo shuffle of the big side
spark.sql.autoBroadcastJoinThresholdAuto-broadcast size (default 10 MB)
built-in F.* functions over Python UDFsOptimiser-visible, JVM-native
pandas_udf for vectorised custom logicWhen a UDF is unavoidable
coalesce(n) vs repartition(n)Reduce files without shuffle vs rebalance with shuffle
cache()/persist() only for reused DataFrames; unpersist()Memory is finite

Skew and spill

df.groupBy(k).count().orderBy(F.desc('count'))Find hot keys first
spark.sql.adaptive.skewJoin.enabled=trueAQE splits skewed partitions
filter hot key, process separately, unionManual skew handling
salt: rand()*N on big side, crossJoin(range(N)) on small sideSpread a hot key across N tasks
spill -> more partitions / executor memory / less cacheMemory pressure fixes

UI and plan signals

PartitionFilters / PushedFilters in FileScanPruning and pushdown working
BroadcastHashJoin vs SortMergeJoinSmall side broadcast vs both sides shuffled
Exchange hashpartitioning(...)A shuffle; check its bytes
task max >> medianSkew
BatchEvalPythonPython UDF in the plan
Executors tab: GC time, failed tasksMemory problems

Common pitfalls

  • Calling collect() or toPandas() on a large DataFrame and crashing the driver.
  • Joining a big table to a small one with a sort-merge join because the small side was not broadcast.
  • A NULL or default key concentrating a shuffle on one task for an hour.
  • Python UDFs for logic that built-in functions cover, ten times slower and opaque to the optimiser.
  • Writing thousands of tiny output files because the shuffle partition count was left at 200 for a small result.
  • Standing up a Spark cluster for 50 GB that DuckDB would finish on a laptop.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →