All notes Note N01 Data Engineering

Spark performance:
a field guide.

Most Spark slowness isn't a memory shortage — it's skew and bad partitioning. This is the order I work through, with the snippets I actually paste.

Updated 2025.10·6 sections·6 min
01

Diagnose before you tune

The fastest way to waste an afternoon is to raise executor.memory on a job that's actually skewed. Open the Spark UI, find the slow stage, and look at two numbers: the number of tasks and the max task duration vs. the median.

If your longest task runs 10× the median, you have skew. No amount of memory fixes skew — it just delays the OOM.
02

Size your partitions

Aim for partitions of roughly 128–256 MB. Too few and you can't parallelise; too many and the scheduler drowns in overhead. Repartition on a high-cardinality key before any wide join.

# too few partitions = no parallelism; too many = scheduler overhead
df = df.repartition(400, "user_id")

# check the spread
df.rdd.glom().map(len).collect()
03

Find and fix skew

Skew shows up as one or two tasks that never finish. Confirm it by counting rows per key — if a handful of keys dominate, that's your problem.

# which keys are hot?
(df.groupBy("user_id")
   .count()
   .orderBy(col("count").desc())
   .show(20))

For the join itself, enable adaptive query execution — it splits skewed partitions automatically on recent Spark versions.

spark.conf.set("spark.sql.adaptive.enabled", True)
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", True)
04

Salting hot keys

When AQE isn't enough, salt manually: spread each hot key across N buckets, join on the salted key, then drop the salt. It trades a little shuffle for a lot of balance.

# spread hot keys across 16 buckets
df = df.withColumn("salt", (rand() * 16).cast("int"))
keyed = df.withColumn("jkey",
    concat_ws("_", col("user_id"), col("salt")))
05

Memory, in the right order

Only after partitioning is sane should you touch memory — and raise overhead before heap. Most surprise kills are off-heap: shuffle buffers and Python workers, not the JVM heap.

--conf spark.executor.memory=8g
--conf spark.executor.memoryOverhead=2g   # raise this first
--conf spark.sql.shuffle.partitions=400
Rule of thumb: memoryOverhead ≈ 10–20% of executor memory, more if you're heavy on PySpark UDFs.
06

The 30-second checklist

Before escalating to "we need a bigger cluster," run this in order:

# 1. partitions sane?     ~128–256MB each
# 2. skew?                max/median task time
# 3. AQE on?              adaptive + skewJoin
# 4. salt hot keys        if AQE not enough
# 5. overhead before heap memoryOverhead first
# 6. THEN scale the cluster

Nine times out of ten you never reach step six. The cluster was big enough; the work just wasn't balanced.