PySpark / Performance Fig_02  ·  Lesson 02 / 14
Fig_02 · Spark joins · v1.0 OSS Spark + Azure Databricks

Spark joins

Sort-merge · shuffle-hash · broadcast

The same logical join can be a sub-second lookup or a cluster-melting shuffle. Spark picks one of three physical strategies from its size estimate of each side — get that estimate (or the threshold) right and a big-vs-small join costs almost nothing; get it wrong and you shuffle terabytes for no reason. This lesson is about choosing — and verifying — the right one.

Spark 3.2+ / DBR LTS autoBroadcastJoinThreshold 10 MB shuffle.partitions 200 verified Jun 2026

01 · What it is

One join, three ways to run it

A join combines two DataFrames on a key. Spark can run that one logical join three physically different ways, and the choice decides whether the job moves gigabytes across the network or almost nothing. In one line each — the mechanisms are the deep dive in §3:

analogy A join needs matching keys on the same executor. Either move the data to the keys (shuffle both sides — everyone walks to the sorting desk) or move a copy of the small table to every executor (broadcast — hand each worker their own lookup card). Every strategy is just a different answer to "how do we get matching keys together?"

The one rule to remember Spark chooses the strategy from its size estimate of each side. Below autoBroadcastJoinThreshold (10 MB) a side is broadcast and the big side never moves; above it, both sides shuffle. The whole lesson is: keep the estimate honest, and read the plan to confirm which one you got.
What a join must do

Get rows with the same key onto the same executor — either move the data to the keys, or copy the small side everywhere.

Broadcast — the fast path

Small side copied to every executor; the big side is never shuffled. A sub-second lookup for big fact ⋈ small dimension.

Sort-merge — the cost

Both sides shuffled and sorted. Correct for big-vs-big, but you pay a full double shuffle across the network.

02 · Why it matters

The shuffle is the most expensive thing a join can do

03 · How it works — deep dive

The three strategies, and how Spark chooses

Each strategy is a different trade between shuffling data and copying a small table everywhere. Below is the one canonical explanation of all three — the diagrams, the comparison table, and the gotchas later point back here rather than re-explaining. Read each as: the mechanism, when Spark picks it, and the trade-off.

The three strategies at a glance — the prose below unpacks each row; this is the map to scan first:

StrategyHow it runsWhen Spark picks itPlan node
Broadcast Hash (BHJ)Copy small side to every executor; big side never shuffled, each task probes the in-memory hash tableOne side estimated ≤ 10 MB (OSS), or a broadcast() / BROADCAST hintBroadcastHashJoin + BroadcastExchange
Sort-Merge (SMJ)Both sides shuffled by key, each partition sorted, then merged in lockstepDefault for big-vs-big; preferSortMergeJoin = true and neither side broadcastableSortMergeJoin + 2× Exchange + Sort
Shuffle Hash (SHJ)Both sides shuffled by key, then one side built into a per-partition hash table (no sort), the other probes itNeither broadcastable and preferSortMergeJoin = false (or SHUFFLE_HASH hint), one side fits per partitionShuffledHashJoin + 2× Exchange

3.1 · Broadcast Hash Join (BHJ) — the fast path

use case orders (2 TB) ⋈ country_codes (8 KB) — broadcast the country table; the 2 TB is read once, never shuffled.

3.2 · Shuffle Sort-Merge Join (SMJ) — the default for big-vs-big

use case transactions (1 TB) ⋈ accounts (300 GB) on account_id — neither fits in memory, so sort-merge is correct.

3.3 · Shuffle Hash Join (SHJ) — the rare middle ground

3.4 · How Spark chooses — the decision order

Every plan you'll read comes out of this ladder, top to bottom:

  1. Is there a hint? Priority: BROADCAST → MERGE → SHUFFLE_HASH → SHUFFLE_REPLICATE_NL.
  2. Else, is one side estimated ≤ 10 MB? → Broadcast Hash Join.
  3. Else, an equi-join with preferSortMergeJoin = true → Shuffle Sort-Merge Join.
  4. Else (small-ish side, preferSortMergeJoin = false) → Shuffle Hash Join.
  5. No equi-key → nested-loop / Cartesian (slow — usually a bug).
Where the estimate goes wrong A filter or aggregation before the join shrinks a side — but pre-AQE Spark may not re-estimate it, so it keeps planning a shuffle. That gap is exactly what AQE (Lesson 05) closes at runtime, and why a manual broadcast() is sometimes the fix.
Broadcast join ≠ broadcast variable A broadcast join is a join strategy the planner picks (driven by autoBroadcastJoinThreshold / the BROADCAST hint). A broadcast variable (Lesson 09) is a value you ship to workers to reference inside a UDF or map. Same word, different tool — interviewers love the mix-up.

Figure A · the tipping point

Watch the plan flip at 10 MB

Drag the small side's estimated size. As it crosses the 10 MB threshold the chosen strategy flips Broadcast Hash Join ⇄ Sort-Merge Join, the plan redraws (one BroadcastExchange vs two Exchange nodes), and the "big side shuffled?" readout flips with it. Toggle Force broadcast() to override the estimate. This figure isolates one thing — where the decision boundary is; where the rows physically move is Figure B.

join-strategy chooser

A 2 TB orders table joined to a country dimension of the size you choose (OSS threshold = 10 MB).

—
chosen strategy
—
2 TB orders shuffled?
—
relative cost

Simplified illustration — real selection also weighs join type, stats quality, and AQE's runtime sizes.

04 · The code — and how to verify it

Three moves, each with its proof

Every technique below is paired with how to prove it worked — the plan node or Spark-UI signal. Apply, then verify; never assume.

Simple reading rule When you read a join plan, ask two questions: "Which side is small enough to broadcast?" and "Does the plan show one BroadcastExchange (broadcast) or two Exchange nodes (both sides shuffled)?" The answer tells you which strategy you actually got.

Force (or guarantee) a broadcast join

pythonfrom pyspark.sql.functions import broadcast

# orders is huge; country is tiny. broadcast() tells the planner:
# ship `country` to every executor and DO NOT shuffle `orders`.
joined = orders.join(broadcast(country), on="country_code", how="inner")

# VERIFY — .explain(mode="formatted") must show a BroadcastHashJoin, a
# BroadcastExchange on the country side, and NO Exchange on the orders side:
joined.explain(mode="formatted")
#   *(n) BroadcastHashJoin ... (orders.country_code = country.country_code)
#   :- *(n) <orders scan, no Exchange>          <-- big side NOT shuffled  ✅
#   +- BroadcastExchange ...                    <-- small side broadcast    ✅

Equivalent Spark SQL hint — same effect, same plan node:

sql-- The hint goes immediately after SELECT. BROADCAST(country) == broadcast(country).
SELECT /*+ BROADCAST(country) */ o.*, c.country_name
FROM   orders o
JOIN   country c ON o.country_code = c.country_code;
-- VERIFY: same BroadcastHashJoin + BroadcastExchange in the Spark UI SQL tab.

Tune the auto-broadcast threshold, deliberately

python# Raise the threshold so a ~50 MB dimension still auto-broadcasts (OSS default 10 MB).
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 50 * 1024 * 1024)  # bytes

# Or disable auto-broadcast entirely to FORCE a sort-merge join (to A/B a plan):
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)  # -1 = never auto-broadcast

# VERIFY — re-run .explain() and confirm the join node flipped
# (BroadcastHashJoin <-> SortMergeJoin), then reset the conf.

Contrast: naive big-vs-small vs a forced broadcast

python# NAIVE: if Spark mis-estimates `country` as big, it shuffles the 2 TB orders table.
slow = orders.join(country, "country_code")            # may plan SortMergeJoin → double shuffle

# RIGHT: force the broadcast so only the tiny side moves.
fast = orders.join(broadcast(country), "country_code")  # BroadcastHashJoin → no big-side shuffle

# VERIFY — Spark UI → SQL tab: `fast` has NO Exchange on the orders branch;
# `slow` shows two Exchange nodes + Sort + SortMergeJoin. Stages tab: `slow`
# has large Shuffle Read/Write on the orders side; `fast` has none.

Figure B · where the rows go

See it run across the cluster

This figure isolates a different thing from Figure A: not the boundary, but where the data physically lands. Step a broadcast join across 1 driver + 3 executors — watch only the tiny dim cross the wire while the big orders partitions stay put — then flip to the sort-merge join and watch both sides shuffle. The contrast is the whole lesson. Use Prev / Play / Next.

broadcast vs sort-merge, across the nodes

orders (big, one partition per executor) ⋈ country (tiny dim). Compare how each strategy executes.

Driver main()

plan only

Executor 1 P0

Executor 2 P1

Executor 3 P2

step 1 / 5
PySpark — dummy code
What's on the wire

Simplified illustration — 3 executors and a handful of rows stand in for a real cluster of thousands of partitions.

05 · Decision table

Which strategy — at a glance

Read this by the situation you're in — it answers "which plan should I expect, and what's the risk?" without re-explaining the mechanisms (that's §3).

DimensionBroadcast HashSort-MergeShuffle Hash
ShuffleNone (broadcasts small side)Both sides shuffledBoth sides shuffled
Sort?NoYes (both sides)No
Best whenOne side ≤ ~10 MB (fits memory)Both sides largeOne side small-ish, sort too costly
Default triggerside ≤ threshold (10 MB) / BROADCAST hintpreferSortMergeJoin=trueneither broadcastable & preferSortMergeJoin=false / SHUFFLE_HASH
Main riskDriver/executor OOM if too bigCost of the double shuffle + sortHash-table OOM on a skewed partition
Plan nodeBroadcastHashJoin + BroadcastExchangeSortMergeJoin + 2× Exchange + SortShuffledHashJoin + 2× Exchange

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of the three strategies:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
Reading the plan wrongA BroadcastExchange on your big table means you're broadcasting the wrong side.Confirm the BroadcastExchange sits on the small side.
Forcing broadcast on a table that growsAn 8 MB dim today is an 80 MB OOM next quarter.Prefer AQE; hint only with a known size cap.
Bumping the threshold globallyIt changes every join in the session, not just this one.Scope it, or use a per-query broadcast() / hint.
Assuming a small DataFrame broadcastsThe output of a wide transformation may be estimated as huge.Check .explain() before assuming the plan node.
Disabling AQE to "control" joinsYou lose the runtime SMJ→broadcast switch and skew-join split.Keep AQE on — it usually beats hand-tuning (Lesson 05).

08 · References

Sources