Shuffle Hash Join (SHJ) is a join strategy in Apache Spark that combines shuffling with hash-based joining.
It works by shuffling both datasets based on join keys to co-locate matching keys in the same partition, then building an in-memory hash table from the smaller dataset’s partitions (also called the build side) and probing it with the larger dataset.
Internal Architecture and Stages
Shuffle Hash Join execution involves two main phases with multiple underlying stages:
Phase 1: Shuffle Phase
This phase redistributes data so that rows with the same join key end up on the same executor partition.
Here’s the step-by-step process:
- Source Data Reading: Both left and right datasets are read from their sources (HDFS, S3, etc).
- Partition Key Extraction: For each row, Spark extracts the join key and computes a partition ID using HashPartitioning
- Uses Murmur3Hash function on the join key expressions
- Applies the modulo operation
- Shuffle Write (Map Side):
- Each partition of the source data is processed by a shuffle map task
- Rows are serialized and written to the local disk as shuffle files
- Shuffle metadata (locations, sizes) is registered with the MapOutputTrackerMaster on the driver
- Creates intermediate shuffle files organized by (mapId, reduceId) pairs.
- Shuffle Read (Reduce Side):
- Executors fetch shuffle blocks from remote executors based on partition assignments
- MapOutputTracker coordinates which blocks to fetch from which executors
- Data is transferred over the network and deserialized into memory
- After the shuffle, all rows with the same join key reside in the same partition
Phase 2: Hash Join Phase
Perform a local hash join within each partition
Here’s the step by step process:
- Build Side Selection:
- Spark selects the smaller side as the “build side” based on statistics
- The larger side becomes the “probe side”
- This decision is made per partition after the shuffle
- HashedRelation Construction:
- For each partition, the build side creates a HashedRelation (in-memory hash table):
LongHashedRelation: Used when the join key is a singleLongTypeexpressionUnsafeHashedRelation: Used for all other key types (composite keys, strings, etc.)
- The hash table structure maps:
hash(joinKey) → List[InternalRow] - This happens in the
buildHashedRelationmethod ofShuffledHashJoinExec
- For each partition, the build side creates a HashedRelation (in-memory hash table):
- Probe and Match:
- The physical operator
ShuffledHashJoinExecexecutes viadoExecute()methoddoExecute() { streamedPlan.execute() // Get probe-side RDD buildPlan.execute() // Get build-side RDD // Zip partitions together streamedRDD.zipPartitions(buildRDD) { (streamIter, buildIter) => // Build hash table from build side val hashedRelation = HashedRelation(buildIter, buildKeys, ...) // Probe with stream side join(streamIter, hashedRelation, numOutputRows) } } - For each row in the probe side:
- Extract the join key using
streamSideKeyGenerator() - Hash the key and look it up in the HashedRelation
- If a match exists, create a
JoinedRowcombining both sides - Apply any additional join conditions (filter predicates)
- Emit matched rows to the output
- Extract the join key using
- The physical operator
- Join Type Handling:
- Inner Join: Return rows only when matches exist in hash table
- Left Outer Join: Return all probe-side rows, with nulls when no match
- Right Outer Join: Return all build-side rows, with nulls when no match
Attention
Shuffle Hash Join is not selected for Full Outer Joins
Visual representation of both the Phases
flowchart TD OrdersTable[Left Table: Orders] -- "Hash Partitioning" --> OrdersShuffleWrite[Shuffle Write - Orders] CustomersTable[Right Table: Customers - Build side] -- "Hash Partitioning" --> CustomersShuffleWrite[Shuffle Write - Customers] subgraph PartitionedShuffleFiles OrdersShuffleWrite --> OrdersPart1[Orders Partition 1] OrdersShuffleWrite --> OrdersPart2[Orders Partition 2] CustomersShuffleWrite --> CustPart1[Customers Partition 1] CustomersShuffleWrite --> CustPart2[Customers Partition 2] end OrdersPart1 -- "Shuffle Read" --> Exec1[Executor 1] CustPart1 -- "Shuffle Read" --> Exec1 OrdersPart2 -- "Shuffle Read" --> Exec2[Executor 2] CustPart2 -- "Shuffle Read" --> Exec2 subgraph ExecutorProcessPerPartition Exec1 --> Build1[Build HashedRelation from Customers Partition 1] Build1 --> Probe1[Probe: Each Order in Orders Partition 1, lookup in Hash Table] Probe1 --> JoinOut1[Join Output 1] Exec2 --> Build2[Build HashedRelation from Customers Partition 2] Build2 --> Probe2[Probe: Each Order in Orders Partition 2, lookup in Hash Table] Probe2 --> JoinOut2[Join Output 2] end
Example for concrete understanding
- Orders table: 10 million rows, 5 GB
- Customers table: 100,000 rows, 50 MB
- Join key:
customer_id - Configuration:
spark.sql.shuffle.partitions = 200
-
Optimizer Decision The optimizer chooses Shuffle Hash Join because:
- Customers (50 MB) cannot be broadcast (threshold is 10 MB)
- Orders (5 GB) / Customers (50 MB) = 100x ratio (much larger than 3x factor)
preferSortMergeJoinis disabled
-
Shuffle Phase - Orders (Probe Side): For each order row with
customer_id = 12345:- Compute hash:
hash = Murmur3Hash(12345) - Partition assignment:
partitionId = hash % 200→ suppose result is partition 47 - Write to shuffle file:
shuffle_1_0_47.data(shuffle_shuffleId_mapId_reduceId.data) This happens for all 10 million orders across all map tasks.
- Compute hash:
-
Shuffle Phase - Customers (Build Side): For each customer row with
customer_id = 12345:- Compute hash:
hash = Murmur3Hash(12345)(same hash function) - Partition assignment:
partitionId = hash % 200→ partition 47 (same as orders!) - Write to shuffle file:
shuffle_2_0_47.dataThis happens for all 100,000 customers.
- Compute hash:
-
Shuffle Read: Executor E1 responsible for partition 47:
- Fetches all shuffle blocks for partition 47 from orders (from multiple executors)
- Fetches all shuffle blocks for partition 47 from customers (from multiple executors)
- Now has all
customer_id = 12345rows from both tables in local memory
-
Hash Table Construction (Partition 47): From the 500 customer rows in partition 47, build HashedRelation:
{ 12345 → [Row(12345, "Alice", "NY")], 12346 → [Row(12346, "Bob", "CA")], 12347 → [Row(12347, "Charlie", "TX")], ... } -
Probe and Join Phase (Partition 47): For each order in partition 47 (50,000 orders):
1. Order: (order_id=9001, customer_id=12345, amount=100.0) 2. Extract join key: 12345 3. Look up in hash table: hashedRelation.get(12345) 4. Found: [Row(12345, "Alice", "NY")] 5. Create JoinedRow: (9001, 12345, 100.0, 12345, "Alice", "NY") 6. Emit to output -
Parallel Execution: All 200 partitions are processed in parallel across available executors. The final result is the union of all partition outputs.
Stage Breakdown: When this is viewed in Spark UI, you’ll see (assuming an action has been called):
- Stage 0: Read Orders and shuffle write → ShuffleMapStage
- Stage 1: Read Customers and shuffle write → ShuffleMapStage
- Stage 2: Shuffle read both sides + hash join + count → ResultStage
Performance Metrics on Spark UI
This is how Hash Join metrics look in Spark UI

All the metrics present here are self-explanatory:
data size of build side: Size of data used to build the hash tabletime to build hash map: Time taken to construct the HashedRelationnum unique hash keys: Total number of unique hash keys in the generated hash mapnum of output rows: Total number of rows produced by the join
Spark Configurations impacting Shuffle Hash Join
spark.sql.join.preferSortMergeJoin (default=true): Must befalseto prefer shuffle hash joinspark.sql.shuffledHashJoinFactor (default=3): Size ratio threshold for build side selection, i.e., shuffle hash join can be selected if the data size of small side multiplied by this factor is still smaller than the large side.spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold (default=0b): Max partition size for AQE to convert SMJ to SHJ (0 = disabled)spark.sql.shuffle.partitions: Number of shuffle partitions used while definingpartitionId.