PySpark / Performance Fig_11  ·  Lesson 11 / 14
Fig_11 · Bucketing · v1.0 OSS Spark + Azure Databricks

Bucketing

Pre-shuffle once on write · no Exchange on read

When you can't broadcast and you join two big tables on the same key every day, you re-pay the full double shuffle on every run. Bucketing pays that cost once, on write — and removes the Exchange from every future join on that key. Move the shuffle from read time to write time, then reuse it forever.

Spark 3.2+ / DBR LTS bucketing.enabled true coalesceBucketsInJoin false (3.1+) verified Jun 2026

01 · What it is

Pre-shuffle a table by its key, once, on write

Bucketing physically pre-shuffles a table by a key once, when you write it, and stores the result as a fixed number of hash-partitioned files — the "buckets." Any later join or aggregation on that key can then skip the shuffle, because the data already sits exactly where the engine would otherwise have moved it.

analogy A shuffle join is two delivery firms each re-sorting all their parcels by postcode at the depot, every morning, before they can be matched. Bucketing is pre-sorting the warehouse shelves by postcode once — every future match just reads the right shelf.

Three moves make a bucketed table, and the rest of this lesson keeps pointing back to them rather than re-listing:

The one rule to remember The expensive part of a shuffle join is the Exchange that moves both sides across the network. Bucketing pays that cost once on write and reuses it on every read — but only if both tables share the same key and the same bucket count. Mismatch either and the shuffle comes right back.
What bucketing does

Pre-shuffles a table by a key once, on write, into a fixed number of hash files (hash(key) % n) so later joins can skip the shuffle.

Same-N buckets → no Exchange

Both tables bucketed by the same key into the same n → layouts match → the planner drops the pre-join Exchange.

Mismatched buckets → shuffle

Different key or different bucket count → HashPartitioning differs → the shuffle returns on one side.

02 · Why it matters

The one tool for big ⋈ big on a repeated key

03 · How it works — deep dive

Write once, and every future join inherits the layout

A sort-merge join (Lesson 02) needs matching keys in the same partition on both sides; it gets there by shuffling both sides into spark.sql.shuffle.partitions (200) hash buckets, then sorting and merging. Bucketing performs that exact hash-partitioning ahead of time and stores it. Each idea below is explained here once — later sections show it, verify it, and help you decide, but don't re-explain it.

3.1 · Writing a bucketed table (bucketBy + sortBy + saveAsTable)

use case a 2 TB transactions table joined to a 300 GB accounts table on account_id every day — bucket both by account_id into the same n once; every daily join then runs with no Exchange.

3.2 · Matching bucket counts → no Exchange (the core mechanism)

analogy two decks of cards already sorted into the same 256 numbered trays — to find matching pairs you compare tray-to-tray; you never re-deal.

3.3 · Bucketing vs partitioning

These are the two on-disk layout tools, and they solve different problems — the single distinction people most often blur:

Bucketing ≠ partitioning Partitioning (partitionBy, Lesson 07) → directories, one per value; great for pruning a low-cardinality filter column (date, region). Bucketing (bucketBy) → a fixed number of files by hash; great for join/agg keys, often high-cardinality (user id, account id) — exactly the columns you must NOT partition by, or you'd explode into millions of tiny directories.

They stack, they don't compete: partitionBy("event_date").bucketBy(256, "user_id").sortBy("user_id") prunes by date and removes the shuffle on user_id joins.

use case an events table partitioned by event_date (one directory per day) and bucketed by user_id (no shuffle on the join to users).

3.4 · Mismatched counts & coalesceBucketsInJoin (Rung 3)

3.5 · Classic bucketing vs the DataSource-V2 flag (don't confuse them)

Bucketing vs AQE vs broadcast AQE (Lesson 05) coalesces post-shuffle partitions — it never retro-fits bucketing, and a one-sided bucketing still shuffles the other side. Broadcast (Lesson 02) removes the shuffle only when a side is small. Bucketing is the tool for big ⋈ big on the same key, repeatedly.

Figure A · where the rows go

See it run across the cluster

Step a daily transactions ⋈ accounts join on account_id across 1 driver + 3 executors. First the unbucketed shuffle moves both tables over the network; then bucketing pre-places matching keys so the buckets are co-located and the join runs locally with no Exchange; finally watch what a bucket-count mismatch brings back. Use Prev / Play / Next — the thing to watch is whether the wire between nodes lights up (a shuffle) or stays dark (co-located, local join).

bucketed join across the nodes

Chips are records colored by their key bucket. t = transactions row, a = accounts row. A lit wire = data crossing the network.

Driver plans · schedules

Executor 1 bucket 0

Executor 2 bucket 1

Executor 3 bucket 2

step 1 / 7
stagenarration
PySpark
# daily big ⋈ big join on account_id t = spark.table("transactions") a = spark.table("accounts") joined = t.join(a, "account_id") # shuffles both # --- bucket ONCE on write --- t.write.bucketBy(3,"account_id").sortBy("account_id")\ .saveAsTable("transactions_bkt") # 3 buckets a.write.bucketBy(3,"account_id").saveAsTable("accounts_bkt") tb.join(ab, "account_id") # no Exchange
Records by key bucket
t: acct 100 → hash%3 = 0
t: acct 211 → hash%3 = 1
t: acct 322 → hash%3 = 2
a: acct 100 → hash%3 = 0
a: acct 211 → hash%3 = 1
a: acct 322 → hash%3 = 2

Simplified illustration — 3 executors / 3 buckets and a handful of rows stand in for hundreds of buckets and billions of rows. The mechanism is identical.

04 · The code — and how to verify it

Four moves, each with its proof

Every technique below is paired with how to prove it worked — the .explain() plan node or the Spark-UI signal. The proof for bucketing is always the absence of Exchange under the join. Apply, then verify; never assume.

Simple reading rule Before you trust any bucketed join, ask one question: "Are both sides bucketed by the join key into the same number of buckets?" If yes, the plan should show a SortMergeJoin with no Exchange. If either the key or the count differs, the shuffle is still there.

Write two tables bucketed by the same key into the same N

python# Bucket BOTH tables by the join key (account_id) into the SAME number of buckets.
# bucketBy(n, key) -> HashPartitioning(account_id) into n files (the pre-shuffle)
# sortBy(key)      -> sort within each bucket so the read-side join skips its Sort too
# saveAsTable(...) -> REQUIRED: bucketing only works through a metastore/UC table
N = 256

(transactions.write
    .bucketBy(N, "account_id").sortBy("account_id")
    .mode("overwrite")
    .saveAsTable("main.pyspark_perf.transactions_bucketed"))

(accounts.write
    .bucketBy(N, "account_id").sortBy("account_id")   # SAME key, SAME N
    .mode("overwrite")
    .saveAsTable("main.pyspark_perf.accounts_bucketed"))

# VERIFY the stored bucket spec:
spark.sql("DESCRIBE EXTENDED main.pyspark_perf.transactions_bucketed").show(50, False)
#   -> Num Buckets: 256   Bucket Columns: [account_id]   Sort Columns: [account_id]

spark.sql.sources.bucketing.enabled is true by default — you don't set it to use bucketing; you'd only set it to false to prove the before-state.

Join the bucketed tables and confirm the Exchange is gone

pythont = spark.table("main.pyspark_perf.transactions_bucketed")
a = spark.table("main.pyspark_perf.accounts_bucketed")

joined = t.join(a, "account_id")        # same key both tables are bucketed on

# VERIFY: a SortMergeJoin with NO Exchange under either side = shuffle eliminated.
joined.explain(mode="formatted")
#   == Physical Plan ==
#   * SortMergeJoin [account_id], Inner
#   :- * FileScan ... transactions_bucketed   <- no Exchange
#   +- * FileScan ... accounts_bucketed        <- no Exchange

Contrast: the unbucketed join (the shuffle you're removing)

python# Unbucketed big-vs-big join: both sides shuffled (two Exchange nodes).
slow = transactions.join(accounts, "account_id")
slow.explain(mode="formatted")
#   * SortMergeJoin [account_id], Inner
#   :- * Sort  +- Exchange hashpartitioning(account_id, 200)  <- 2 TB shuffled
#   +- * Sort  +- Exchange hashpartitioning(account_id, 200)  <- 300 GB shuffled
# VERIFY: Spark UI SQL tab — `slow` has two Exchange nodes; the bucketed join has none.

Stack partitioning (prune) with bucketing (no shuffle)

python# Prune by a low-cardinality filter column AND remove the shuffle on the join key.
(events.write
    .partitionBy("event_date")                     # directories -> partition pruning (L07)
    .bucketBy(256, "user_id").sortBy("user_id")     # hash files  -> no shuffle on user_id
    .mode("overwrite")
    .saveAsTable("main.pyspark_perf.events_bpb"))

# VERIFY: the plan shows PartitionFilters on event_date AND no Exchange on the user_id join.

Equivalent Spark SQL — bucketing declared in DDL:

sql-- CLUSTERED BY ... INTO n BUCKETS is the SQL form of bucketBy/sortBy.
CREATE TABLE main.pyspark_perf.accounts_bucketed
CLUSTERED BY (account_id) SORTED BY (account_id) INTO 256 BUCKETS
AS SELECT * FROM main.pyspark_perf.accounts_raw;

Figure B · the shuffle eliminator

Watch the Exchange disappear

This figure isolates a different thing from Figure A: not where the rows land, but what the plan does. Toggle "bucket both by the key" and watch the Exchange vanish from the join plan. Then mismatch the bucket counts — the shuffle returns — and flip coalesceBucketsInJoin to rescue the multiple-of case. Watch the "Exchange before join?" readout and the plan box.

bucket both, same N → no Exchange

A 2 TB transactions table joined to a 300 GB accounts table on account_id — too big to broadcast.

transactions — bucket files
accounts — bucket files
—
join layout
—
Exchange before join?
—
per-run shuffle cost
transactions bucket accounts bucket

Simplified illustration — bucket files drawn small; real tables use hundreds of buckets. Co-located = same key set in the same-numbered bucket on both sides.

05 · Decision table

Bucketing vs the layouts it sits beside

Read this by the problem you're solving — it answers "which tool removes which cost?" at a glance. (The why is §3; this is the which.)

DimensionBucketing (bucketBy)Partitioning (partitionBy)Broadcast join (L02)
Physical layoutFixed N files by hash of the keyOne directory per valueNo layout change; ships a copy at runtime
Best column typeJoin / agg key (often high-cardinality)Low-cardinality filter (date, region)n/a (about table size)
What it removesThe join/agg Exchange (shuffle)Files you don't scan (pruning)The big-side shuffle
Cost paidFull shuffle + sort once on writeMany dirs; explosion if high-cardinalityDriver/executor memory for small side
Best whenBig ⋈ big on a key, repeatedlyYou filter on that column oftenOne side ≤ ~10 MB
RequirementsaveAsTable(); same key + same NpartitionBy on writeSmall side fits memory
Verify in planSortMergeJoin, no ExchangePartitionFilters: [...]BroadcastHashJoin + BroadcastExchange

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of §3:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
save() / insertInto() / jdbc() with bucketByBucketing is silently ignored — it needs a registered table.Use saveAsTable().
Bucketing only one side of the joinThe other side still shuffles to match, so you paid a write-time shuffle for nothing.Bucket both, same key and same N.
Assuming coalesceBucketsInJoin is onIt defaults to false (since 3.1.0) and only helps for multiple-of counts.Opt in explicitly, or just use the same N.
Mismatched counts (200 vs 256)The HashPartitioning differs, so the shuffle returns on one side.Pick one number (a power of two is common) and standardize it.
Confusing the classic flag with the V2 flagThey are different features — citing the wrong one misleads.sources.bucketing.enabled ≠ sources.v2.bucketing.enabled.

08 · References

Sources