Skip to main content
Articles/#apache-spark/spark-broadcast-joins-vs-sort-merge-joins
Series: Data Engineering & Spark Masteryβ€’Part 11 of 19
Scenario Article

Broadcast Joins vs Sort-Merge Joins in Spark

The 45-Minute Join Stage That Became 90 Seconds

Abstract Algorithmsβ€’Apr 19, 2026
XLinkedInReddit
πŸ’‘ Executive Summary

A data engineering team at a retail company was running a nightly Spark job that joined their 500 GB transaction fact table against a 50 MB product dimension table.

🎯 System Scenario & Requirements

System Goal

A data engineering team at a retail company was running a nightly Spark job that joined their 500 GB transaction fact table against a 50 MB product dimension table.

Actors & Components

  • Application or API tier
  • Core service under design
  • Operator or SRE observer

Functional Requirements

  • A data engineering team at a retail company was running a nightly Spark job that joined their 500 GB transaction fact table against a 50 MB product dimension table.
  • The job had been in production for six months.
  • Whenever the business added a new data source, the join stage crept up β€” first 20 minutes, then 35, eventually settling into a reliable 45-minute runtime that delayed every downstream report.

Constraints & Assumptions

  • Stale statistics cause more BHJ misses than anything else.
  • The most common reason Spark plans SMJ on a small table is not a configuration problem β€” it is missing or stale table statistics.

⚑ Execution Stages & Decision Trace

1

Stage 1 β€” The Five Spark Join Strategies and When Catalyst Selects Each

Action / Decision:

Spark's Catalyst optimizer knows five join strategies.

Component Behavior:

They differ in how much data moves across the network, how much memory they require, and what classes of join they can handle.

Observable Output: Catalyst selects among them using a rule cascade applied to size estimates at planning time β€” before a single row of data is read.
Read Full Stage in Deep Dive ↓
πŸ“–

Deep Dive

Detailed Architectural Analysis

Scenario context

A data engineering team at a retail company was running a nightly Spark job that joined their 500 GB transaction fact table against a 50 MB product dimension table. The job had been in production for six months. Whenever the business added a new data source, the join stage crept up β€” first 20 minutes, then 35, eventually settling into a reliable 45-minute runtime that delayed every downstream report.

A senior engin…

This walkthrough focuses on The 45-Minute Join Stage That Became 90 Seconds.

Before making a design choice, clarify:

  • A data engineering team at a retail company was running a nightly Spark job that joined their 500 GB transaction fact table against a 50 MB product dimension table.
  • The job had been in production for six months.
  • Whenever the business added a new data source, the join stage crept up β€” first 20 minutes, then 35, eventually settling into a reliable 45-minute runtime that delayed every downstream report.

Concepts in play: spark-data-engineering, llm-agents-and-langgraph, jvm-internals-and-garbage-collection, database-indexing

Stage 1 β€” The Five Spark Join Strategies and When Catalyst Selects Each

  • Spark's Catalyst optimizer knows five join strategies.
  • They differ in how much data moves across the network, how much memory they require, and what classes of join they can handle.
  • Catalyst selects among them using a rule cascade applied to size estimates at planning time β€” before a single row of data is read.
  • Broadcast Hash Join (BHJ) Catalyst selects BHJ when one side of the join has a size estimate below spark.sql.autoBroadcastJoinThreshold (default 10 MB ).
flowchart TD
    Driver["Driver Node"]
    Planner["Query Planner"]
    Optimizer["Catalyst Optimizer"]
    Executor["Executor Task"]

    Driver -->|"Spark Execution"| Planner
    Planner --> Optimizer
    Optimizer -->|"Optimized Plan"| Executor
    Executor -->|"Result"| Driver

Observable outcome: Broadcast Hash Join (BHJ) Catalyst selects BHJ when one side of the join has a size estimate below spark.sql.autoBroadcastJoinThreshold (default 10 MB ).

Stage 2 β€” How BHJ, SMJ, and SHJ Execute at the Executor Level

  • Broadcast Hash Join: The Driver Collects, Executors Probe The BHJ execution flow has three phases and no shuffle: Phase 1 β€” Collection and serialization.
  • The driver (or an executor acting as coordinator) collects the entire small table from all partitions into a single dataset, serializes it using Java serialization (or Kryo if configured), and stores it as a broadcast v…
  • The size of the serialized object is what gets checked against autoBroadcastJoinThreshold .
  • Spark distributes the broadcast variable to all executors.
flowchart LR
    Mapper["Mapper
(Shuffle Write)"] Network["Network
(Shuffle Read)"] Reducer["Reducer
(Aggregate)"] Mapper -->|"Partitioned Data"| Network Network -->|"Shuffled
Partitions"| Reducer

Observable outcome: Spark distributes the broadcast variable to all executors.

Stage 3 β€” Inside the Broadcast Variable and How Join Strategy Performance Actually Scales

  • The Internals of Broadcast Hash Join The broadcast variable lifecycle has subtle memory implications that are worth understanding in detail.
  • When the driver serializes the small table, the serialized byte array is held in driver memory until all executors have acknowledged receipt.
  • For a 200-executor cluster broadcasting a 200 MB table, the driver allocates 200 MB and holds it for the duration of the job.
  • The executors each deserialize their own copy β€” 200 MB Γ— 200 executors = 40 GB of cluster memory consumed by the broadcast before a single join row is processed.

Observable outcome: The executors each deserialize their own copy β€” 200 MB Γ— 200 executors = 40 GB of cluster memory consumed by the broadcast before a single join row is processed.

Stage 4 β€” Controlling Join Strategy in Production: Hints, Bucketing, and AQE Tuning

  • Forcing a Broadcast When Catalyst Underestimates The most common production scenario requiring manual intervention: a table is filtered from 2 GB to 30 MB before the join, but Catalyst planned the join before knowing th…
  • Because statistics were collected on the full 2 GB table, Catalyst estimates 2 GB and plans SMJ.
  • The join runs for 40 minutes when it should take 2.
  • The broadcast() hint wraps a DataFrame and instructs Catalyst to force BHJ regardless of its size estimate.

Observable outcome: The broadcast() hint wraps a DataFrame and instructs Catalyst to force BHJ regardless of its size estimate.

Stage 5 β€” When Each Strategy Becomes a Liability

  • Broadcast Hash Join: Memory Risk Multiplied by Executor Count BHJ's memory consumption scales with the number of executors, not the number of partitions.
  • Broadcasting a 200 MB table to a 300-executor cluster requires 60 GB of cluster memory to be allocated simultaneously β€” 200 MB per executor, held for the entire duration of the join stage.
  • On a shared cluster where multiple jobs run concurrently, each with its own broadcast joins, executor OOM errors become likely long before any individual broadcast exceeds the threshold.
  • The driver is also vulnerable: it must collect and hold the entire broadcast table in heap memory before distribution begins.
flowchart TD
    Input["Input Stream"]
    Batch1["Micro-Batch [t, t+Ξ”t)"]
    Process["Processing"]
    Output["Output"]

    Input -->|"Split by interval"| Batch1
    Batch1 --> Process
    Process --> Output
    Output -->|"Repeat"| Input

Observable outcome: The driver is also vulnerable: it must collect and hold the entire broadcast table in heap memory before distribution begins.

Stage 6 β€” Reading the Physical Plan to Verify Your Join Strategy

  • The physical plan output from df.explain() is the ground truth for which join strategy Spark actually chose.
  • Before running any join-heavy job in production, call explain() and confirm the plan matches your intent.
  • The following examples show what to look for in each case.
  • This section demonstrates three scenarios: confirming a successful broadcast hash join, identifying an accidental sort-merge join on a small table, and spotting the most dangerous outcome β€” an unintended cartesian produ…
== Physical Plan ==
*(2) BroadcastHashJoin [transaction_id#10], [txn_id#42], Inner, BuildRight
:- *(2) Project [transaction_id#10, merchant_id#11, amount#12]
:  +- *(2) Scan parquet fact_transactions [...]
+- BroadcastExchange HashedRelationBroadcastMode(List(input[0, bigint, false]))
   +- *(1) Project [txn_id#42, product_name#43, category#44]
      +- *(1) Scan parquet dim_products [...]
== Physical Plan ==
*(5) SortMergeJoin [transaction_id#10], [txn_id#42], Inner
:- *(2) Sort [transaction_id#10 ASC NULLS FIRST], false, 0
:  +- Exchange hashpartitioning(transaction_id#10, 200)
:     +- *(1) Scan parquet fact_transactions [...]
+- *(4) Sort [txn_id#42 ASC NULLS FIRST], false, 0
   +- Exchange hashpartitioning(txn_id#42, 200)
      +- *(3) Scan parquet dim_products [...]

Observable outcome: This section demonstrates three scenarios: confirming a successful broadcast hash join, identifying an accidental sort-merge join on a small table, and spotting the most dangerous outcome β€” an unintended cartesian produ…

End-to-end outcome

After working through the stages, you should be able to explain:

  • TLDR: Spark knows five join strategies; Catalyst picks the wrong one whenever it lacks accurate table size statistics.
  • Broadcast Hash Join is 30Γ— faster than Sort-Merge Join for small dimension tables because it eliminates both shuffles entirely β€” the small table travels once as a broadcast variable, and every executor joins locally.

Alternatives considered

Broadcast Hash Join vs Sort-Merge Join: Execution Path Comparison

The two diagrams below capture the fundamental structural difference between BHJ and SMJ.

Choosing the Right Join Strategy: A Size and Memory Decision Guide

This table maps the key variables β€” small-side table size, large-side table size, and available executor memory β€” to the recommended join strategy.

Failure and degraded modes

Lessons Learned From Real Spark Join Failures

  • Detection: Stale statistics cause more BHJ misses than anything else.
  • User or operator impact: Stale statistics cause more BHJ misses than anything else.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit