Databases

The Nested Loop Join

The simplest way to join two tables — for every outer row, scan the inner

A nested loop join is the simplest join algorithm: for every row of the outer table it scans the entire inner table, emitting each pair that satisfies the join predicate. In its naive tuple-at-a-time form it runs in O(n·m) — quadratic when the tables are the same size — but two refinements rescue it: the block nested loop join buffers a chunk of outer pages to slash inner-table re-reads, and the index nested loop join replaces the inner scan with a B-tree probe to reach O(n log m). It is the join a query optimizer reaches for when one side is tiny or the inner column is indexed, and it is the only join that natively handles non-equality predicates.

  • Time (naive, CPU comparisons)O(n · m)
  • I/O (block nested loop)np + ⌈np/(B−2)⌉ · mp
  • Time (index nested loop)O(n log m)
  • SpaceO(1) naive · O(B) block
  • HandlesAny predicate (=, <, BETWEEN, spatial)
  • Optimizer picks it forSmall outer + indexed inner

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

Why the nested loop join matters

Every relational database ships three workhorse join algorithms — nested loop, hash, and sort-merge — and the nested loop is the foundation the other two are measured against. It is the join you can write on a napkin: two for loops. Yet despite its brutal worst case, it remains the algorithm the optimizer picks most often in real OLTP workloads, because the typical transactional query filters the outer table down to a handful of rows and joins them against an indexed inner. In that regime the nested loop is not a fallback — it is optimal.

Understanding it well is the difference between a query that returns in a millisecond and one that never finishes. The classic production incident is a plan that "flips" to a nested loop join on two large tables because the optimizer's row estimate was wrong: instead of a hash join over a few million rows, you get billions of index probes or, worse, a full inner scan per outer row. Reading a query plan and recognizing a dangerous nested loop is a core database skill.

How it works, step by step

Label the two inputs the outer (or driving) table and the inner (or probed) table. The algorithm is exactly two loops:

  • Outer loop. Iterate over every row r of the outer table, once.
  • Inner loop. For each r, iterate over every row s of the inner table.
  • Predicate test. If join_predicate(r, s) is true, emit the combined row (r, s).

The inner table is therefore read once for every outer row. If the outer has n rows and the inner has m rows, the number of predicate evaluations is exactly n·m — the Cartesian product, filtered. That product is the whole story of its cost, and also of its danger: it is quadratic when n ≈ m.

The choice of which table drives matters enormously. Because the inner is rescanned n times, you want the smaller table on the outside when neither is indexed (fewer rescans of the inner), but the indexed table on the inside when one has an index (so each probe is a cheap seek). A good optimizer decides this for you using row and index statistics; a bad plan gets it backwards.

Counting I/O, not just comparisons

CPU comparisons are only half the picture. Databases live and die by disk I/O, and the nested loop's I/O cost depends on which variant you run. Let np and mp be the number of disk pages in the outer and inner tables, and let B be the number of buffer-pool pages available to the join.

  • Tuple-at-a-time nested loop. Read the inner table once per outer row — I/O cost np + n · mp. This is the pathological version and is essentially never used as-is.
  • Page nested loop. Read the inner table once per outer page — I/O cost np + np · mp. Better, but the inner is still re-read np times.
  • Block nested loop. Buffer B − 2 pages of the outer at a time (one page reserved for the inner scan, one for output). The inner is now scanned only ⌈np / (B − 2)⌉ times, giving I/O cost np + ⌈np / (B − 2)⌉ · mp.

The block variant is why the join scales with memory: hand it a bigger buffer and the inner-table re-reads drop proportionally. In the limit where the entire outer fits in the buffer (B − 2 ≥ np), the inner is scanned exactly once and the cost collapses to np + mp — a single sequential pass over each table. That is the best a block nested loop can do, and it explains why "put the smaller table on the outside" is the standard rule: a small outer is more likely to fit in the buffer whole.

The index nested loop join

The block variant fixes the I/O of the inner rescan, but it still touches every inner page. The index nested loop join avoids the scan entirely. If the inner table has a B-tree index on the join column, then for each outer row you take the join key and perform an index lookup — retrieving only the matching inner rows instead of scanning all m.

A B-tree lookup is O(log m), so the total cost becomes O(n log m) in comparisons, and in I/O it is roughly np + n · (cost of one index probe), where a probe is typically 2–4 page reads (the B-tree height plus a heap fetch). When n is small this is astonishingly cheap — which is exactly why adding an index to a foreign-key column so often turns a multi-second join into a sub-millisecond one. The optimizer notices the index exists, switches from a block nested loop to an index nested loop, and the plan cost drops by orders of magnitude.

There is a subtlety: an index nested loop does one random-access probe per outer row, and random I/O is far more expensive than sequential. If the outer is large, thousands of scattered index seeks can be slower than a single sequential hash-join build. This is precisely the threshold the optimizer's cost model tries to find — and the threshold it most often gets wrong when statistics are stale.

Nested loop vs hash vs sort-merge join

Nested loop (naive)Index nested loopHash joinSort-merge join
TimeO(n · m)O(n log m)O(n + m)O(n log n + m log m)
Extra memoryO(1)O(1)O(min(n, m)) for buildO(1) if pre-sorted
Join predicateAny (=, <, BETWEEN)Any indexableEquality onlyEquality / range
Needs an index?NoInner must be indexedNoNo (but likes sorted input)
Streams first rows early?YesYesNo (blocking build)No (blocking sort)
Optimizer picks it whenBoth tables tinySmall outer + indexed innerLarge unindexed equijoinInputs already sorted

The headline: hash join is the linear-time champion for large equijoins, and sort-merge wins when the data is already ordered (or when you need sorted output anyway). The nested loop's niche is the small-and-selective case and the non-equijoin case that neither competitor can serve. Note too that hash and sort-merge both block — they must build a hash table or sort before emitting a single row — whereas a nested loop streams results immediately, which is why LIMIT / first-row-fast queries often prefer it.

Implementation

The three variants in Python-flavoured pseudocode. Note how little changes between them — the outer loop is identical; only the inner access path differs.

# 1. Naive (tuple-at-a-time) nested loop join
def nested_loop_join(outer, inner, match):
    for r in outer:              # n rows
        for s in inner:          # m rows, re-scanned every time
            if match(r, s):      # any predicate: =, <, BETWEEN, ...
                yield (r, s)     # streams results immediately
    # Comparisons: n * m


# 2. Block nested loop join — buffer B-2 pages of the outer at a time
def block_nested_loop_join(outer_pages, inner_pages, match, B):
    block_size = B - 2           # 1 page for inner scan, 1 for output
    for block in chunks(outer_pages, block_size):
        rows_in_block = [row for page in block for row in page]
        for page in inner_pages:            # inner scanned once PER BLOCK
            for s in page:
                for r in rows_in_block:
                    if match(r, s):
                        yield (r, s)
    # Inner re-reads: ceil(n_pages / (B-2))  — memory buys fewer scans


# 3. Index nested loop join — probe a B-tree on the inner join key
def index_nested_loop_join(outer, inner_index, match):
    for r in outer:                          # n rows
        for s in inner_index.lookup(r.key):  # O(log m) B-tree probe
            if match(r, s):                  # index gives candidates only
                yield (r, s)
    # Comparisons: n * log(m)  — the small-outer / indexed-inner sweet spot

In a real engine, outer and inner are iterators over pages, match is the compiled join predicate, and inner_index.lookup descends a B+-tree. PostgreSQL's nodeNestloop.c and MySQL/InnoDB's Batched Key Access (a block-plus-index hybrid) are the production embodiments of the third form.

When the optimizer chooses a nested loop

  • Tiny outer input. After filtering, the outer produces only a few rows. A nested loop with an indexed inner does a few cheap probes and wins outright — no build phase, no sort, minimal memory.
  • Selective index on the inner join column. When the inner has a B-tree on the join key and each key matches few rows, the index nested loop's O(n log m) beats a hash join's build cost.
  • Non-equijoins. Range predicates (a.ts BETWEEN b.start AND b.end), inequalities, and spatial matches cannot drive a hash join. The nested loop is the fallback and often the only option.
  • Correlated subqueries. A subquery that references the outer row is, semantically, a nested loop — evaluate the inner query once per outer row.
  • First-row-fast queries. With LIMIT 10 or an interactive cursor, the nested loop's ability to stream results without a blocking build is a decisive advantage.

Common misconceptions and pitfalls

  • "Nested loop join is always slow." False. It is the optimal plan for small-outer / indexed-inner joins, which dominate OLTP. It is only catastrophic on large, unindexed inputs.
  • Confusing the naive and block variants. The block nested loop does the same O(n·m) comparisons but drastically less I/O. Almost every "nested loop" in a real engine is really a block or index nested loop.
  • Forgetting that driving-table choice matters. Put the smaller table on the outside for un-indexed joins, but the indexed table on the inside. Getting it backwards can be 100× slower.
  • A plan that flips to nested loop on two big tables. This is the classic stale-statistics incident: the optimizer under-estimates the outer row count, picks a nested loop, and the query does billions of probes. Fix with fresher statistics or a join hint.
  • Assuming an index always helps. If the outer is large, thousands of scattered random index seeks can be slower than one sequential hash-join scan. Random I/O has a real cost the cost model must weigh.
  • Ignoring cache effects. A small inner table that fits in the buffer pool is re-scanned from memory, not disk — so a "nested loop over a big product" can still be fast if the inner is hot in cache.

A worked example

Join orders (outer, 50 rows after a WHERE customer_id = 42 filter) to line_items (inner, 5,000,000 rows) on order_id.

  • Naive nested loop: 50 × 5,000,000 = 250,000,000 comparisons, plus a full scan of line_items for every one of the 50 orders. Unusable.
  • Index nested loop (B-tree on line_items.order_id): 50 probes × ~3 page reads each = ~150 page reads. Sub-millisecond. This is the plan every optimizer picks — and why a foreign-key index on order_id is mandatory.

Now flip it: join all orders (5,000,000 rows) to all customers (5,000,000 rows) on customer_id with no filter. A nested loop — even indexed — pays 5,000,000 random probes. Here a hash join that builds a hash table on the smaller side in one pass and streams the larger side through it wins decisively. Same operator family, opposite algorithm — and the boundary between them is exactly what the query optimizer exists to find.

Frequently asked questions

What is the time complexity of a nested loop join?

The naive (tuple-at-a-time) nested loop join runs in O(n·m), where n and m are the row counts of the outer and inner tables — every outer row is paired against a full scan of the inner. Measured in disk I/O the naive page cost is O(n_pages · m_pages). The block nested loop join keeps the same O(n·m) CPU comparison count but cuts I/O to roughly n_pages + ceil(n_pages / (B-2)) · m_pages by buffering B-2 pages of the outer at a time. The index nested loop join replaces the inner scan with a B-tree lookup, dropping the cost to O(n log m) (or near O(n) with a hash index).

When does the query optimizer choose a nested loop join over a hash or merge join?

The optimizer favours a nested loop join when the outer input is small (a few rows after filtering) and the inner table has a selective index on the join column — then each probe is an O(log m) index seek and the total cost stays tiny. It also wins for correlated subqueries, non-equijoins (range or inequality predicates that hash and merge joins cannot handle directly), and when returning the first rows quickly matters, because a nested loop can stream results without a blocking build phase. Hash join wins for large unindexed equijoins; merge join wins when both inputs are already sorted on the join key.

What is the difference between a nested loop join and a block nested loop join?

A naive nested loop join reads the inner table once per outer row (or once per outer page), so its inner table is re-read n times. A block nested loop join buffers a block of B-2 pages of the outer table in memory, then scans the inner table once per block instead of once per row. This does not change the O(n·m) number of tuple comparisons, but it slashes the number of times the inner table is read from disk — the inner is scanned only ceil(n_pages / (B-2)) times, turning a random-I/O disaster into a manageable sequential one.

How does an index nested loop join work?

An index nested loop join keeps the outer loop but replaces the inner full scan with an index probe. For each outer row it takes the join key and performs a lookup on a B-tree (or hash) index built on the inner table's join column, retrieving only the matching inner rows. Because a B-tree lookup is O(log m), the total cost falls to O(n log m) instead of O(n·m). It is the fastest join when the outer side is small and the inner has a suitable index — this is why creating an index on a foreign-key column so often speeds up joins dramatically.

Why is a nested loop join slow on large tables?

Because its work grows as the product of the two table sizes. Joining two 100,000-row tables with a naive nested loop is 10 billion comparisons; if each also triggers a page read, the disk I/O is catastrophic. The cost is quadratic when the tables are the same size, so doubling both inputs quadruples the work. On large unindexed inputs a hash join (roughly linear in the total number of rows) or a merge join is dramatically faster. The nested loop only stays cheap when one side is small or the inner is indexed.

Can a nested loop join handle non-equality join conditions?

Yes — and this is one of its key advantages. Because it simply evaluates the join predicate for every outer-inner pair, a nested loop join works with any condition: ranges (a.ts BETWEEN b.start AND b.end), inequalities (a.price > b.threshold), spatial or fuzzy matches, and correlated subqueries. Hash join requires an equality predicate to build hash buckets, and merge join requires an orderable equality on sorted inputs, so both fall back to a nested loop for non-equijoins. The trade-off is that without an index the nested loop pays the full O(n·m) cost.

How much memory (buffer) does a nested loop join need?

The naive nested loop join needs almost no memory — one page for the outer, one for the inner, one for output. The block nested loop join is the memory-hungry variant: it dedicates B-2 of the B available buffer pages to holding a chunk of the outer table, leaving one page for the inner scan and one for output. Giving it more buffer pages directly reduces how many times the inner table is re-read, so a block nested loop join scales gracefully with available RAM, unlike the naive form whose I/O cost is fixed at the tuple level.