Introduction
When you're joining a large fact table with a small dimension table in Spark, the default behavior shuffles both tables across the cluster—a massive waste of network bandwidth and CPU cycles. Broadcast joins offer a elegant solution: replicate the small table to every executor and let the join happen locally. In this guide on broadcast joins: a Spark & Data Eng guide for developers, we'll show you how to leverage this technique to achieve dramatic speedups in production pipelines, and when to avoid the pitfalls that catch teams off guard.
Understanding the broadcast() hint
Spark's broadcast() function is a hint that tells the optimizer to replicate a DataFrame to all executors before joining. Instead of shuffling both tables across the network (the sort-merge join strategy), Spark copies the smaller table into memory on every node and performs a hash join locally.
Here's the simplest form:
from pyspark.sql.functions import broadcast
# fact_df: 10 million rows
# dim_df: 500 rows
result = fact_df.join(
broadcast(dim_df),
on='product_id',
how='inner'
)
That's it. The broadcast() function wraps the DataFrame with a hint that Spark will respect during execution. Without it, Spark might choose a shuffle-based join if the statistics are unclear or if the table size is uncertain.
You can also use SQL syntax:
spark.sql("""
SELECT f.*, d.category
FROM fact_table f
INNER JOIN /*+ BROADCAST(dim_table) */ dim_table d
ON f.product_id = d.product_id
""")
Small table copied to every executor
When you broadcast a table, Spark serializes it, sends it over the network once, and caches it in memory on every executor node. The join then happens entirely within each executor's JVM without any shuffle.
Let's trace through a concrete scenario: you have a cluster with 8 executors, a fact table with 100 million order records, and a dimension table with 1,000 product records (roughly 10 MB after serialization).
- Shuffle join (default): Spark partitions both tables by product_id, shuffles data across the network (both tables), and merges sorted partitions on each node. This can easily move gigabytes of data.
- Broadcast join: Spark sends the 10 MB product table once to each of 8 executors (80 MB total network traffic), stores it in memory, and each executor joins its partition of the 100 million fact records against the local copy of the product table.
The memory footprint on each executor is small: 10 MB for the broadcast table plus the working memory for that partition's join operation. The network savings are dramatic—you're eliminating the shuffle of the entire fact table.
In practice, we've seen joins that took 45 seconds with shuffle drop to 8 seconds with broadcast on fact tables in the 500 million row range.
Huge speedups for dimension tables
Broadcast joins shine for dimension tables: product catalogs, customer metadata, geographic hierarchies, regulatory lookups, anything that changes slowly and stays relatively small.
Consider a real e-commerce scenario: you ingest 50 million order events per day. Each event references a product, a customer segment, a region, and a promotion. You join against four dimension tables (products: 5K rows, segments: 200 rows, regions: 50 rows, promotions: 500 rows). All told, the dimension data is 200 MB.
With shuffle joins, Spark would shuffle 50 million order rows four times across the cluster. With broadcast joins:
orders_df = spark.read.parquet('s3://data/orders/')
products_df = spark.read.parquet('s3://data/dim_products/')
segments_df = spark.read.parquet('s3://data/dim_segments/')
regions_df = spark.read.parquet('s3://data/dim_regions/')
promotions_df = spark.read.parquet('s3://data/dim_promotions/')
result = (orders_df
.join(broadcast(products_df), 'product_id')
.join(broadcast(segments_df), 'segment_id')
.join(broadcast(regions_df), 'region_id')
.join(broadcast(promotions_df), 'promo_id')
)
Each join is now a local hash join. The fact table is never shuffled. Total execution time drops from 12 minutes to under 2 minutes on a typical cluster.
For SaaS and trading platforms, this is equally powerful. A trading system ingesting millions of price ticks can join against a small table of instrument metadata (name, exchange, lot size, currency). A CRM system can join customer event logs against account hierarchies and business rules. The pattern is universal: large stream of facts, small reference data.
Watch memory limits
The tradeoff is memory. If you broadcast a 5 GB table to 20 executors, you're consuming 100 GB of cluster memory just for the broadcast. If each executor already has limited memory for its working set, broadcast can cause out-of-memory errors or spilling to disk (which defeats the performance gain).
Spark has a built-in safeguard: spark.sql.autoBroadcastJoinThreshold, which defaults to 10 MB. Any table larger than this threshold will not be auto-broadcast, even if one side is small. You must explicitly use broadcast() to override it.
Check your broadcast size before you deploy:
dim_df = spark.read.parquet('s3://data/dim_large/')
print(f"Broadcast size (bytes): {dim_df.count() * 100}") # rough estimate
print(f"Available executor memory: {spark.sparkContext.getConf().get('spark.executor.memory')}")
A more precise check uses Spark's internal serialization:
from pyspark.serializers import PickleSerializer
serializer = PickleSerializer()
size_bytes = len(serializer.dumps(dim_df.collect()))
print(f"Serialized size: {size_bytes / (1024**3):.2f} GB")
As a rule: if the broadcast table is larger than 1–2 GB, start questioning whether broadcast is right. If it's larger than 25–30% of your available executor memory, don't broadcast. Use a shuffle join or repartition your data.
Also watch for skew in the fact table. If one partition is much larger than others, broadcast won't help—the join on that partition will still take much longer. Repartitioning the fact table by the join key can help, but it reintroduces a shuffle.
Common mistakes
Mistake 1: Forgetting to call collect() on a small table. If you've filtered a large table down to a small result, call .persist() or .cache() before broadcasting to force the computation. Otherwise, Spark will recompute the filter every time the broadcasted table is used.
dim_df = large_table_df.filter(col('is_active') == True) # Still lazy
result = fact_df.join(broadcast(dim_df), 'id') # dim_df computed each time—bad
# Better:
dim_df = large_table_df.filter(col('is_active') == True).persist()
result = fact_df.join(broadcast(dim_df), 'id')
Mistake 2: Broadcasting inside a loop. Each broadcast creates a new cached copy. If you broadcast the same table multiple times in a loop, you'll bloat memory and cluster state.
dim_df = spark.read.parquet('...')
for table_name in ['orders', 'shipments', 'returns']:
fact_df = spark.read.parquet(f's3://data/{table_name}/')
result = fact_df.join(broadcast(dim_df), 'product_id').write.parquet(f's3://out/{table_name}/')
# dim_df is broadcasted three times. Cache it once instead:
dim_df = spark.read.parquet('...').persist()
for table_name in ['orders', 'shipments', 'returns']:
fact_df = spark.read.parquet(f's3://data/{table_name}/')
result = fact_df.join(broadcast(dim_df), 'product_id').write.parquet(f's3://out/{table_name}/')
Mistake 3: Ignoring Spark's statistics. Spark can auto-broadcast if it knows a table is small enough. But if you disable stats collection or if stats are stale, Spark may choose shuffle. Always check the query plan:
result.explain()
Look for BroadcastHashJoin in the output. If you see SortMergeJoin, the table wasn't broadcast, and you should investigate why.
Key takeaways
- Use
broadcast(small_df)to replicate a small table to all executors and join locally, eliminating network shuffle. - Broadcast joins are ideal for dimension tables: products, customers, regions, rules—anything small and referenced frequently.
- Expect 5–10x speedups on joins where one table is under 1 GB and the other is hundreds of millions of rows.
- Monitor broadcast memory consumption; if the table exceeds 25% of executor memory or 2–3 GB, reconsider.
- Cache filtered or computed tables before broadcasting to avoid recomputation.
- Always inspect the query plan with
.explain()to confirm the join strategy. - Broadcast joins don't help with skewed data; repartition or use bucketing if join keys are uneven.






Comments
Loading comments…