Performance Tuning: Partitioning, Caching, and Shuffles
A practical tuning checklist — when and how to cache, fixing data skew, choosing partition counts, and reading executor memory metrics before they turn into out-of-memory failures.
- Part 1 — What Spark Is and Why It Exists
- Part 2 — Installing Spark and Your First Job
- Part 3 — RDDs: The Original Building Block
- Part 4 — DataFrames and Spark SQL
- Part 5 — Reading and Writing Data
- Part 6 — Joins, Aggregations, and Window Functions
- Part 7 — Jobs, Stages, and Tasks
- Part 8 — Performance Tuning
- Part 9 — Structured Streaming
- Part 10 — Deploying Spark to Production
Part 7 built the mental model — jobs, stages, tasks, shuffles. Now let’s use it to actually make slow jobs fast.
Caching: when it helps, when it hurts
Caching keeps a DataFrame’s computed result in memory (or disk) across multiple actions, avoiding recomputation from source:
1
2
3
4
5
filtered = spark.read.parquet("events.parquet").filter(col("status") == "active")
filtered.cache()
count1 = filtered.count() # computes and caches
top10 = filtered.orderBy(desc("ts")).limit(10).collect() # reuses cache
Cache when a DataFrame is:
- Reused multiple times — if you only touch a DataFrame once, caching just adds overhead for no benefit.
- Expensive to recompute — the result of a costly join or aggregation, not a cheap filter on already-cached data.
Storage levels, via .persist(StorageLevel...):
1
2
3
4
5
from pyspark.storagelevel import StorageLevel
df.persist(StorageLevel.MEMORY_ONLY) # fastest, lost if it doesn't fit
df.persist(StorageLevel.MEMORY_AND_DISK) # spills to disk if it doesn't fit — the safe default
df.persist(StorageLevel.DISK_ONLY) # for data too big for memory, still avoids recompute
MEMORY_AND_DISK is the sensible default for most cases — MEMORY_ONLY risks silently dropping cached partitions (forcing recomputation later) if memory pressure gets high.
Always release a cache you no longer need:
1
filtered.unpersist()
Check the Storage tab in the Spark UI to see exactly what’s cached, how much memory it’s using, and whether any of it has spilled to disk — a quick way to catch a cache that isn’t earning its keep.
Diagnosing data skew
Skew is when one or a few partitions hold dramatically more data than the rest — usually because a groupBy or join key is unevenly distributed (a handful of customer IDs with 90% of all orders, for example). You’ll see it in the Spark UI as one or two tasks taking far longer than the rest of a stage.
Check for skew directly:
1
2
3
from pyspark.sql.functions import spark_partition_id, count
df.groupBy(spark_partition_id()).agg(count("*").alias("rows")).orderBy(desc("rows")).show()
If one partition has 50x the rows of the median, that’s your skew.
1
2
3
4
5
6
Balanced partitions Skewed partitions
┌───┬───┬───┬───┬───┐ ┌───┬───┬───┬───┬────────┐
│ 20│ 22│ 19│ 21│ 20│ │ 12│ 10│ 8│ 11│ 200 │
└───┴───┴───┴───┴───┘ └───┴───┴───┴───┴────────┘
all tasks finish 4 tasks finish fast,
around the same time 1 task drags the whole stage out
Common fixes:
- Salting: append a random suffix to a skewed key to spread it across more partitions, then aggregate in two steps (once on the salted key, then again on the real key). Manual, but effective for extreme skew.
- Adaptive Query Execution (AQE): since Spark 3.0, enabled by default in recent versions, AQE detects skewed partitions at runtime and automatically splits them into smaller sub-partitions. Confirm it’s on:
1
2
spark.conf.set("spark.sql.adaptive.enabled", True)
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", True)
AQE also dynamically coalesces small shuffle partitions and can switch a sort-merge join to a broadcast join at runtime if it learns one side turned out to be small after filtering — it’s genuinely one of the best “free” performance wins in modern Spark, and worth confirming is enabled before doing manual tuning.
Choosing partition counts
From Part 7: too many partitions means task-scheduling overhead dominates; too few means underused parallelism and long individual tasks.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Check current partition count
df.rdd.getNumPartitions()
# Reduce partitions without a full shuffle (only merges adjacent partitions —
# use when reducing count, e.g. before writing fewer, larger output files)
df.coalesce(10)
# Change partition count with a full shuffle (use when increasing count,
# or when you need genuinely even redistribution, not just merging)
df.repartition(50)
# Repartition by a column — useful before a groupBy/join on that column,
# or to control output file layout by key
df.repartition(20, "region")
coalesce is cheaper because it avoids a full shuffle — it just combines existing partitions — but it can’t increase partition count or fix uneven distribution. repartition shuffles everything to guarantee even-sized output partitions, at the cost of a shuffle.
Reading executor memory metrics
The Executors tab in the Spark UI shows, per executor: memory used, memory available, disk spill, and task/shuffle failures. Spill — data that didn’t fit in memory during a shuffle or aggregation and had to write to disk — is a strong signal you’re memory-constrained:
1
2
3
4
5
Storage Memory ████████████████████░░░░ used / available
Shuffle Read 1.2 GB
Shuffle Write 980 MB
Spill (Memory) 340 MB ← data spilled to disk mid-computation
Spill (Disk) 120 MB
Some spill under heavy load is normal; consistently large spill across most executors usually means either the executor memory allocation is too small for the workload, or a partition count that’s too low (each task handling more data than it should).
Executor memory can be set at submit time:
1
spark-submit --executor-memory 4g --executor-cores 4 --num-executors 10 my_job.py
A basic tuning checklist
When a job is slow, work through these roughly in order — they’re ordered by how often they’re the actual cause, in practice:
- Check the Spark UI: which stage is slow, and is it skewed (uneven task durations)?
- Confirm AQE is enabled.
- Check partition count relative to data size and available cores.
- Look for
groupByKeywherereduceByKey-style pre-aggregation would work (Part 3). - Check whether a join could be a broadcast join instead of a shuffle join (Part 6).
- Look for repeated computation on the same DataFrame that should be cached.
- Check executor memory metrics for spill.
- Only then, consider more advanced options: custom partitioners, bucketing, or increasing cluster size.
Bigger clusters and more memory are a lever, but they’re usually the last one to pull — most real-world slowness comes from skew, an avoidable shuffle, or a missing cache, all of which more hardware doesn’t fix.
What’s next
Everything so far has processed data that already exists at rest. Part 9 introduces Structured Streaming — running the same DataFrame API you already know against data that’s continuously arriving.
Next in the series: Structured Streaming.