Filter orders to EU, join customers, group by tier. How many shuffles does Spark do, and which line of the code caused each one?
The setup — predict the table before reading on
The tables
orders(order_id int, customer_id int, product_id int, region string, order_date string, amount int)
2,000 orders across 3 regions and 30 days.
| order_id | customer_id | product_id | region | order_date | amount |
|---|---|---|---|---|---|
| 0 | 0 | 0 | EU | 2026-03-01 | 5 |
| 1 | 1 | 1 | US | 2026-03-02 | 12 |
| 2 | 2 | 2 | APAC | 2026-03-03 | 19 |
| 3 | 3 | 3 | EU | 2026-03-04 | 26 |
customers(customer_id int, tier string, country string)
400 customers. Small enough to broadcast.
| customer_id | tier | country |
|---|---|---|
| 0 | free | EU |
| 1 | pro | US |
| 2 | free | APAC |
| 3 | pro | EU |
The code — what does Spark do with it?
o = spark.table("orders")
c = spark.table("customers")
out = (o.filter(o.region == "EU")
.join(c, "customer_id")
.groupBy("tier").agg(F.sum("amount").alias("total")))The code — predict the output before reading on
o = spark.table("orders")
c = spark.table("customers")
out = (o.filter(o.region == "EU")
.join(c, "customer_id")
.groupBy("tier").agg(F.sum("amount").alias("total")))Why they ask this
The opening question of almost every Spark plan round. It is asked because the shuffle count is the single best predictor of what a job costs, and because reading it off a plan requires knowing what an Exchange is.
Say this
One. The join is a broadcast so it costs no shuffle at all, and the only Exchange in the plan is the one the groupBy needs to bring each tier together.
The reasoning
Count Exchange nodes — that is what a shuffle is in a physical plan. There is exactly one here, Exchange hashpartitioning(tier, 200), sitting between the two HashAggregate nodes. That is the classic partial-then-final aggregation: Spark aggregates within each partition first, shuffles the much smaller partial results, then combines them.
The join contributed nothing, which is the part worth saying out loud. customers is small enough to sit under the broadcast threshold, so Catalyst chose BroadcastHashJoin — the small side is collected to the driver, shipped to every executor, and the join happens inside the existing partitions. The BroadcastExchange in the tree is a broadcast, not a shuffle: no repartitioning by key, no sort, no shuffle files.
The filter also disappeared from the tree as a separate step, because Catalyst pushed it into the scan — you can see it in the scan's PushedFilters. So the answer to the follow-up question, which is always 'and how many stages', is read off the codegen markers: the *(1), *(2) prefixes number the whole-stage codegen groups, and a new one starts on each side of an Exchange.
What it actually returns 1 shuffle, run on Spark 4.2
Spark 4.2, 200 shuffle partitions, AQE off, broadcast threshold 10MB.
The physical plan1 shuffle
== Physical Plan ==
*(3) HashAggregate(keys=[tier#1], functions=[sum(amount#2)])
+- Exchange hashpartitioning(tier#1, 200), ENSURE_REQUIREMENTS, [plan_id=N]
+- *(2) HashAggregate(keys=[tier#1], functions=[partial_sum(amount#2)])
+- *(2) Project [amount#2, tier#1]
+- *(2) BroadcastHashJoin [customer_id#3], [customer_id#4], Inner, BuildRight, false, false
:- *(2) Project [customer_id#3, amount#2]
: +- *(2) Filter ((isnotnull(region#5) AND (region#5 = EU)) AND isnotnull(customer_id#3))
: +- *(2) ColumnarToRow
: +- BatchScan parquet /tables/orders[customer_id#3, region#5, amount#2] ParquetScan DataFilters: [isnotnull(region#5), (region#5 = EU), isnotnull(customer_id#3)], Format: parquet, Location: InMemoryFileIndex[...], PartitionFilters: [], PushedAggregation: [], PushedFilters: [IsNotNull(region), EqualTo(region,EU), IsNotNull(customer_id)], PushedGroupBy: [], PushedVariantExtractions: [], ReadSchema: struct<customer_id:int,region:string,amount:int> RuntimeFilters: []
+- BroadcastExchange HashedRelationBroadcastMode(List(cast(input[0, int, false] as bigint)),false), [plan_id=N]
+- *(1) Filter isnotnull(customer_id#4)
+- *(1) ColumnarToRow
+- BatchScan parquet /tables/customers[customer_id#4, tier#1] ParquetScan DataFilters: [isnotnull(customer_id#4)], Format: parquet, Location: InMemoryFileIndex[...], PartitionFilters: [], PushedAggregation: [], PushedFilters: [IsNotNull(customer_id)], PushedGroupBy: [], PushedVariantExtractions: [], ReadSchema: struct<customer_id:int,tier:string> RuntimeFilters: []It returns
| tier | total |
|---|---|
| free | 112928 |
| pro | 112041 |
The answer most people give
"Two — one for the join and one for the group by." Only if the join were a sort-merge join. With customers under the broadcast threshold there is no Exchange on either side of it, and reading BroadcastExchange as a shuffle is the most common misreading of a Spark plan.
They’ll ask next
customers grows to 500 MB. Redraw the plan in words, and say how many shuffles it has now.
