◐ ai-teacher

🔥 Spark & Databricks · Chapter 5

Partitioning, bucketing, broadcast joins

Three tools, three different problems: partitioning skips files, bucketing skips shuffles, broadcasting skips the big side entirely.

Study this lesson free in the app →

The basics

Partitioning, bucketing and broadcasting are three ways of laying out or moving data so Spark does less work.

Why they exist: the two most expensive things Spark does are reading data it did not need and shuffling data across the network. Each tool removes one of those:

Mental model: a library. Partitioning is shelving by year so you walk to one aisle. Bucketing is two libraries using the same shelf numbering so matching books are always on matching shelves. Broadcasting is photocopying the one-page index for every reader.

Words you must know 5

partition pruningbucketbroadcast hash joinautoBroadcastJoinThresholdsmall-files problem

Explained

Meghalaya Cabs' analysts query rides almost always with WHERE ride_date = .... The table is one flat folder of 90,000 Parquet files, so every query lists and checks all of them.

Rewritten with partitionBy('ride_date'), a one-day query opens one folder of ~50 files.

Then someone 'improves' it to partitionBy('ride_date', 'city', 'hour'). Now there are 4 million files of 30KB, and the listing alone takes longer than the old full scan.

The library

Shelving by year — you want 2024 books, you walk to one aisle. But shelve by year and author initial and colour, and each shelf holds two books; you spend the day walking.

Two branches with the same numbering — the Shillong and Tura branches both put author 'K' books on shelf 11. To match their catalogues you compare shelf 11 with shelf 11. Nobody has to move books between branches first.

The photocopied index — the 40-page catalogue of rare books is copied for every reader. Nobody queues at one desk; each reader checks their own pile against their own copy.

Three layouts side by side

PARTITIONED (partitionBy ride_date)
s3://mc-lake/silver/rides/
  ride_date=2026-03-13/ part-000.parquet ... part-049
  ride_date=2026-03-14/ part-000.parquet ... part-049   ← WHERE ride_date='2026-03-14'
  ride_date=2026-03-15/ ...                                 reads only this folder

BUCKETED (bucketBy 64 driver_id) — rides and payouts both
  rides:   bucket 00 | bucket 01 | ... | bucket 63
  payouts: bucket 00 | bucket 01 | ... | bucket 63
            ↕ join      ↕ join           ↕ join      ← no Exchange

BROADCAST (drivers = 8MB)
  driver ──collect──► [drivers] ──copy──► executor 1..N
  each task: its rides partition ⋈ local drivers copy ← big side never moves

Writing each layout

# 1. On-disk partitioning: repartition by the same column first,
#    so each date is written by one task → few, large files
(rides.repartition('ride_date')
      .write.partitionBy('ride_date')
      .mode('overwrite').parquet('s3://mc-lake/silver/rides/'))

spark.read.parquet('s3://mc-lake/silver/rides/') \
     .filter(col('ride_date') == '2026-03-14').explain()
# ... PartitionFilters: [isnotnull(ride_date), (ride_date = 2026-03-14)]

# 2. Bucketing: needs a metastore table (saveAsTable), not a path
(rides.write.bucketBy(64, 'driver_id').sortBy('driver_id')
      .saveAsTable('mc.rides_bkt'))
(payouts.write.bucketBy(64, 'driver_id').saveAsTable('mc.payouts_bkt'))
spark.table('mc.rides_bkt').join(spark.table('mc.payouts_bkt'), 'driver_id')
# plan: SortMergeJoin with NO Exchange on either side

# 3. Broadcast
from pyspark.sql.functions import broadcast
rides.join(broadcast(drivers), 'driver_id')     # BroadcastHashJoin
spark.conf.get('spark.sql.autoBroadcastJoinThreshold')  # '10485760' = 10MB

How Spark picks a join strategy

For an equi-join, roughly in this order:

  1. Broadcast hash join — if a hint says so, or one side's estimated size is under autoBroadcastJoinThreshold (10MB), and the join type allows that side to be the build side
  2. Sort-merge join — the default for two large sides: Exchange + Sort both, then merge
  3. Shuffled hash join — shuffle both, hash the smaller per partition; chosen when preferred via config/hint or by AQE when partitions are small
  4. Broadcast nested loop / cartesian — non-equi joins; dangerous at scale

Build-side rule: broadcast must be the side whose unmatched rows are not kept. Inner: either. Left outer/semi/anti: right only. Right outer: left only. Full outer: no broadcast hash join.

The estimate comes from file sizes and table statistics. A 2GB table filtered to 3MB may still be 'too big' to the planner — unless AQE sees the real post-filter size at runtime and converts the sort-merge join into a broadcast join.

At 100×

The small-files problem. Files written by a partitioned write ≈ tasks × distinct partition values per task. 200 shuffle tasks × 365 dates = up to 73,000 files. Repartition by the partition column (or let Delta's optimized writes / OPTIMIZE compact) before writing.

Broadcast memory is driver memory first. The small side is collected to the driver, then shipped. Broadcasting a 1GB table to 200 executors costs driver heap and 200GB of network; there is a hard 8GB limit, and a 5-minute broadcastTimeout. Keep broadcasts in the tens to low hundreds of MB.

Bucketing is fragile. Both tables need the same bucket count and key, written via saveAsTable; each writing task can produce one file per bucket (200 × 64 = 12,800 files). On Databricks/Delta, bucketing is not supported — use Z-order or liquid clustering for data skipping, and rely on broadcast + AQE for joins.

Dynamic partition pruning (Spark 3.0+): joining rides (partitioned by ride_date) to a filtered calendar dimension prunes rides' partitions at runtime.

Gotchas

Partition to skip files. Bucket to skip shuffles. Broadcast to skip the big side.

Partition columns: low cardinality, always filtered. Broadcast: small, non-preserved side.

Words to know

Partition pruningSkipping whole on-disk partition folders whose value cannot match the query's filter.
partitionByDataFrameWriter option that writes one directory per distinct value of the given columns.
BucketingHashing rows into a fixed number of files by a key at write time, so matching joins and aggregations can skip the shuffle.
Broadcast hash joinJoin where the small side is copied to every executor and hashed, and the large side is joined without moving.
autoBroadcastJoinThresholdEstimated size under which Spark broadcasts a join side automatically; default 10MB, -1 disables.
Build sideThe join side loaded into the hash table; for broadcast it must be the non-preserved side of an outer join.
Dynamic partition pruningSpark 3.0+ runtime pruning of a partitioned fact table using the filtered keys of the dimension it is joined to.

In two minutes

Test yourself

1. What is the default value of spark.sql.autoBroadcastJoinThreshold?

  1. 1MB
  2. 100MB
  3. 1GB
  4. 10MB
Show the answer

10MB — 10MB (10485760 bytes). Setting it to -1 disables automatic broadcasting; the broadcast() hint still works.

2. Which column is the best choice for partitionBy on Meghalaya Cabs' rides table?

  1. ride_date
  2. ride_id
  3. driver_id
  4. fare_inr
Show the answer

ride_date — Low cardinality (one value per day), present in nearly every query filter, and each day holds a healthy amount of data. The others would create millions of tiny folders.

3. Which statement about bucketing is true?

  1. It works with any df.write.parquet(path)
  2. Two tables bucketed on the join key with the same bucket count can be joined without an Exchange
  3. It replaces partitionBy
  4. It is the recommended layout for Delta tables
Show the answer

Two tables bucketed on the join key with the same bucket count can be joined without an Exchange — Bucketing requires saveAsTable (metastore) and matching bucket specs to skip the shuffle. Delta does not support it — use Z-order or liquid clustering.

4. You write rides for 30 days with partitionBy('ride_date') right after a groupBy with 200 shuffle partitions. Each task holds rows from every date. Roughly how many files?

  1. 30
  2. 200
  3. Up to 6,000
  4. 1
Show the answer

Up to 6,000 — Each of 200 tasks writes one file per date it holds: 200 × 30. Repartitioning by ride_date first sends each date to one task, giving ~30 files.

5. drivers is 2GB on disk but only 4MB after filter(col('city') == 'Tura'). Without hints, which helps Spark broadcast it?

  1. Nothing — Spark never broadcasts filtered tables
  2. Lowering the threshold to 1MB
  3. Repartitioning drivers to 1 partition
  4. AQE, which sees the real post-filter size at runtime and can convert the sort-merge join to broadcast
Show the answer

AQE, which sees the real post-filter size at runtime and can convert the sort-merge join to broadcast — The static planner estimates from the 2GB scan. AQE re-plans after the shuffle map stage using actual sizes and can switch to a broadcast join.

6. A 40MB promos table is joined to 2TB of rides; a job with broadcast(promos) fails with a driver OOM after someone joined promos to a large audit table first. What happened?

  1. The broadcasted DataFrame is now much bigger than 40MB, and it is collected to the driver before shipping
  2. Broadcast joins never work above 10MB
  3. Executors ran out of disk
  4. The hint was ignored
Show the answer

The broadcasted DataFrame is now much bigger than 40MB, and it is collected to the driver before shipping — The hint forces a broadcast regardless of size, and the broadcast side is materialised on the driver first. An upstream join inflated it. Hints are promises you must keep true.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →