OLAP – Phase 7 Hash Join

Analytical queries rarely touch a single table. “Revenue by region” needs to join orders with regions. “Sales by product category” needs to join sales with products with categories. The hash join is the standard OLAP join algorithm — it builds a hash table from one table (the smaller one), then probes it with the other (the larger one), achieving O(n + m) complexity instead of nested-loop’s O(n x m).

This phase implements the HashJoinOperator with support for INNER and LEFT joins, including NULL key handling.

In DuckDB, this is PhysicalHashJoin (src/execution/operator/join/physical_hash_join.cpp) and JoinHashTable (src/execution/join_hashtable.cpp).

Here is the roadmap for the phases to come:

  • Phase 7: Hash join
  • Phase 8: SQL parser
  • Phase 9: Query planner and optimizer
  • Phase 10: Sorting, parallel execution, REPL, and server

Full Source Code

The code referenced in this post can be found in https://gitlab.com/kimserey.lam/olap-learn.

Build and Probe

The hash join has two phases:

  1. Build: scan the smaller table (build side), insert every row into a hash table keyed by the join columns
  2. Probe: stream the larger table (probe side), look up each row’s join key in the hash table, emit combined rows for matches
1
2
3
4
5
6
7
8
9
10
type HashJoinOperator struct {
    buildChild    PhysicalOperator
    probeChild    PhysicalOperator
    buildKeyExprs []Expression
    probeKeyExprs []Expression
    joinType      JoinType      // JoinInner or JoinLeft
    buildTypes    []vector.LogicalType
    ht            *JoinHashTable
    built         bool
}

Why Smaller Table on Build Side?

The build side’s hash table must fit in memory. If you build from the larger table, you waste memory. If you build from the smaller table, the hash table is small, and the larger table streams through without buffering.


Build Phase

Scan all chunks from the build child, extract join keys, and insert the full row (all columns) into the hash table:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func (op *HashJoinOperator) build() {
    for {
        chunk := op.buildChild.Next()
        if chunk == nil {
            return
        }

        var keyVecs []*vector.Vector
        for _, expr := range op.buildKeyExprs {
            keyVecs = append(keyVecs, expr.Execute(chunk))
        }

        for row := 0; row < chunk.Count(); row++ {
            hasNull := false
            for k, kv := range keyVecs {
                ki := vecIdx(kv, row)
                if !kv.IsValid(ki) {
                    hasNull = true
                    break
                }
                keys[k] = readValue(kv, ki)
            }
            if hasNull {
                continue  // NULL keys never match (SQL standard)
            }

            payload := make([]any, chunk.ColumnCount())
            for col := 0; col < chunk.ColumnCount(); col++ {
                v := chunk.Column(col)
                if !v.IsValid(row) {
                    payload[col] = nil
                } else {
                    payload[col] = readValue(v, row)
                }
            }
            op.ht.Insert(keys, payload)
        }
    }
}

NULL Key Handling

Per the SQL standard, NULL = NULL is false (not true). This means rows with NULL join keys never match — the build phase skips them entirely, and the probe phase treats NULL keys as unmatched.


Probe Phase

For each probe chunk, evaluate the probe key expressions, look up each key in the hash table, and emit combined rows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func (op *HashJoinOperator) probe(probeChunk *vector.DataChunk) *vector.DataChunk {
    // evaluate probe keys
    // ...

    for row := 0; row < count; row++ {
        // extract keys, check for NULLs
        matches := op.ht.Probe(keys)

        if len(matches) == 0 {
            if op.joinType == JoinLeft {
                op.emitLeftUnmatched(probeChunk, row, result)
            }
            continue
        }

        for _, match := range matches {
            outRow := make([]any, len(outTypes))
            // copy probe columns
            for col := 0; col < probeChunk.ColumnCount(); col++ {
                outRow[col] = readValue(probeChunk.Column(col), row)
            }
            // copy build columns from match
            offset := probeChunk.ColumnCount()
            for col := 0; col < len(op.buildTypes); col++ {
                outRow[offset+col] = match.payload[col]
            }
            result.Append(outRow)
        }
    }
    return result
}

Duplicate Keys

A join key might match multiple build-side rows. The JoinHashTable uses chaining (linked list of entries with the same key) to handle duplicates — Probe returns all matches, and the operator emits one output row per match.


LEFT JOIN

A LEFT JOIN keeps all probe-side rows, even when there’s no match on the build side. Unmatched rows get NULLs for the build columns:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (op *HashJoinOperator) emitLeftUnmatched(probeChunk *vector.DataChunk, row int, result *vector.DataChunk) {
    outRow := make([]any, probeChunk.ColumnCount()+len(op.buildTypes))
    for col := 0; col < probeChunk.ColumnCount(); col++ {
        v := probeChunk.Column(col)
        if !v.IsValid(row) {
            outRow[col] = nil
        } else {
            outRow[col] = readValue(v, row)
        }
    }
    // build columns remain nil (NULL)
    result.Append(outRow)
}

Example

1
2
3
4
5
6
7
Build (departments):       Probe (employees):
id | dept_name            name  | dept_id
1  | Engineering          Alice | 1
2  | Sales                Bob   | 2
3  | Marketing            Carol | 1
                           Dave  | 4  (no match)
                           Eve   | 2

INNER JOIN on dept_id = id:

1
2
3
4
Alice | 1 | 1 | Engineering
Bob   | 2 | 2 | Sales
Carol | 1 | 1 | Engineering
Eve   | 2 | 2 | Sales

Dave (dept_id=4) has no match, so he’s excluded.

LEFT JOIN on dept_id = id:

1
2
3
4
5
Alice | 1 | 1    | Engineering
Bob   | 2 | 2    | Sales
Carol | 1 | 1    | Engineering
Dave  | 4 | NULL | NULL
Eve   | 2 | 2    | Sales

Dave is kept, with NULLs for the build columns.


Operator Lifecycle

The complete flow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (op *HashJoinOperator) Next() *vector.DataChunk {
    if !op.built {
        op.build()     // consume all build input → hash table
        op.built = true
    }
    for {
        probeChunk := op.probeChild.Next()
        if probeChunk == nil {
            return nil
        }
        result := op.probe(probeChunk)
        if result != nil && result.Count() > 0 {
            return result
        }
    }
}

The build phase is a pipeline breaker (must see all build input), but the probe phase streams — each probe chunk is processed independently.

Summary

The hash join is O(n + m) — build an in-memory hash table from the smaller table, then stream through the larger table probing for matches. NULL keys never match. LEFT JOIN keeps unmatched probe rows with NULL build columns. Duplicate keys are handled via chaining in the hash table, emitting one output row per match.