The shuffle is the bill
Two Spark jobs read the same data and write the same answer. One costs six times more. The difference is almost always a wide transformation nobody noticed.
A team I know spent two weeks tuning a Spark job. They doubled the executor count, moved to bigger instances, bumped the memory fraction, and got a fourteen percent improvement for roughly twice the money.
Then someone read the plan and deleted a .distinct() that had been added
defensively three years earlier against a source that had since become
deduplicated upstream. The job got four times faster.
That is the shape of nearly every Spark cost story. The compute is not the bill. The shuffle is the bill, and shuffles come from a small, knowable list of operations that look no more expensive in code than the operations that cost nothing at all.
Narrow and wide, and why the distinction is the whole thing
Spark splits your data into partitions and runs one task per partition. The question that determines cost is simple: to compute one output partition, how many input partitions does the task need to read?
If the answer is exactly one, the transformation is narrow. select,
filter, withColumn, map, union — each output partition depends on
exactly one input partition. Spark fuses a whole chain of these into a single
task that streams records through every step without ever writing intermediate
state. A hundred narrow transformations in a row still cost roughly one pass
over the data.
If the answer is “potentially all of them,” the transformation is wide.
To compute the sum for user_id = 7, a task needs every record with that key,
and those records are scattered across every partition in the cluster. There
is no way to get them together without physically moving data between
executors. That movement is the shuffle.
The wide list is short enough to memorise: groupBy and every aggregation
over it, join (unless one side is broadcast), distinct and dropDuplicates,
orderBy and sort, repartition, window functions with a partitionBy,
and intersect/except. Everything else is narrow or nearly so.
What a shuffle actually does
“Data moves between executors” undersells it. A shuffle is a five-part sequence, and each part costs something different.
First, the map side runs your upstream logic and, for every record,
computes a target partition — typically hash(key) % numPartitions. Records
are buffered, sorted or hashed by that target, and written to local disk as
shuffle blocks. This is the part people forget: a shuffle always writes to
disk, even on a cluster with plenty of RAM, because the blocks must survive
long enough for the next stage’s tasks to fetch them and possibly be re-fetched
if a task fails.
Second, everything gets serialized. Rows leave their compact in-memory columnar form and become bytes. Spark’s Tungsten format keeps this cheaper than it used to be, but it is not free, and neither is the compression applied on the way out.
Third, the stage boundary. Spark cannot start the reduce-side tasks until the map side has finished, because a reduce task must be able to fetch from every map task. This barrier is why one slow task holds up the whole job.
Fourth, the network fetch. Every reduce task opens connections to every
executor holding blocks it needs and pulls them. With M map tasks and R reduce
tasks, that is M × R block transfers. At 200 of each — Spark’s default
spark.sql.shuffle.partitions is 200 — that is 40,000 fetches for a single
groupBy.
Fifth, deserialize and merge on the reduce side, with spilling to disk whenever a partition does not fit in the task’s memory budget.
Disk I/O, serialization, network transfer, a synchronisation barrier, and a possible spill. That is why a wide transformation is not “a bit more expensive” than a narrow one. It is a different order of operation.
Finding the Exchange
You do not have to guess which of your operations shuffle. Spark tells you,
in the physical plan, and the node is called Exchange.
df.explain("formatted")
Read the plan bottom-up. Every Exchange hashpartitioning(user_id, 200) is a
shuffle and a stage boundary. Count them: that count, more than any other
number, predicts your runtime. A plan with one Exchange and a plan with five
Exchanges over the same input are not in the same cost class, whatever the
code looks like.
Two other nodes matter when you are reading for cost:
BroadcastExchangeis a shuffle-free join. The small side is collected to the driver, broadcast to every executor, and joined in place. There is no Exchange on the big side at all.AQEShuffleReadappears when adaptive execution has rewritten the plan at runtime — coalescing tiny partitions or splitting skewed ones after seeing the real map-side statistics.
The Spark UI gives you the same story with numbers. Open the Stages tab and look at Shuffle Write and Shuffle Read bytes per stage. If those figures rival or exceed your input size, the job is shuffle-bound, and no amount of extra CPU will help — you are paying for I/O, not compute.
What actually reduces it
In rough order of how often the lever works:
Filter and project before the shuffle, not after. Every column and every
row you drop before an Exchange is bytes you do not serialize, write, send,
and read. Spark’s Catalyst optimizer pushes many filters down for you, but it
cannot push a filter through a UDF it cannot see into, and it cannot drop
columns you selected with *. Select the columns you need, early.
Broadcast the small side of a join. If one side fits comfortably in
executor memory, a broadcast hash join eliminates the shuffle on the large
side entirely. Spark does this automatically when it can estimate the small
side under spark.sql.autoBroadcastJoinThreshold (10 MB by default), which is
conservative and frequently defeated by bad size estimates on views or
freshly-written tables. Raising the threshold and using an explicit
broadcast(df) hint are both legitimate, and both need a real memory check —
an over-eager broadcast turns into a driver OOM.
Stop shuffling the same data repeatedly. If a DataFrame is joined,
aggregated, and joined again on the same key, you may be paying for the same
Exchange three times. Persisting the partitioned result, or writing the
table bucketed or pre-partitioned on the join key, lets a downstream join skip
the shuffle. Bucketing is genuinely underused: the cost is paid once at write
time and refunded on every subsequent join.
Let AQE size the partitions. The 200-partition default is a fixed number applied to inputs of wildly different sizes. Adaptive Query Execution, on by default since Spark 3.2, coalesces small post-shuffle partitions into reasonable ones and splits skewed ones using real runtime statistics. If it is disabled in your cluster config — and in a surprising number of inherited clusters it is — turning it back on is the cheapest win available.
Delete the shuffles you do not need. A distinct() on data that is
already unique. An orderBy before a write, when nothing downstream depends
on the order. A repartition() added to fix a problem that no longer exists.
These are free money, and they are everywhere in code that has been maintained
by more than two people.
Skew: when the shuffle is fine and one task is not
A stage finishes when its slowest task finishes. If one key holds a
disproportionate share of the rows — a null user_id, a default tenant, a
sentinel value like -1, a single enormous customer — then one reduce task
receives an enormous partition while 199 others finish in seconds.
The signature in the Spark UI is unmistakable: a stage where the max task duration is orders of magnitude above the median, and one task with a shuffle read size to match. AQE’s skew join handling splits those oversized partitions automatically in many cases. When it cannot, the manual fix is salting: append a small random integer to the hot key on one side, explode the other side across the same range, join on the composite key, and aggregate the results. It is ugly, and it works.
Before reaching for salt, check whether the hot key is real. Very often the skew is a null or a placeholder that should have been filtered out three stages earlier.
The stance to take
Treat every Exchange as a line item you have to justify. When a job is
slow, the first move is not to add executors — it is to run explain, count
the shuffles, and ask of each one whether the data being moved is actually
needed downstream.
More executors make a compute-bound job faster and a shuffle-bound job more expensive in exactly the same proportion, because more executors means more network endpoints, more blocks, and more fetches. Scaling out a shuffle is paying more to move the same bytes further.
The cheapest shuffle is the one that does not run.
Learn it as a system
Start with Shuffles — Spark’s most expensive operation
for the mechanics of the map side, the reduce side, and the partitioner, then
work through Reading explain plans until you can
find every Exchange in a plan without thinking about it. Finish with
Adaptive Query Execution to see how Spark fixes partition
counts and skew at runtime, and what it still cannot fix for you.