Kathuko › SQL › Part 14: Performance literacy

14.2 19c+

Join methods: hash, nested loops and merge

Every join in a plan is done one of three ways:

Method How Good when
Nested loops for each row of the outer input, look up its matches in the inner (usually through an index) the outer input is small and the inner has an index on the join column
Hash join read the smaller input into a hash table in memory, then stream the bigger one past it big inputs, equality joins, no useful index
Merge join sort both inputs on the join key, then walk them side by side inputs already sorted, or non-equality joins (<, BETWEEN)

A real plan with two hash joins

The 2026 top-five run-scorers, joining deliveries, matches and players. On our 19c Autonomous database:

Top 5 run-scorers of 2026 on 19c Autonomous
  • 17SELECT STATEMENT5 rows
    • 16SORT ORDER BY5 rows
      • 15VIEW5 rows
        • 14WINDOW SORT PUSHED RANK72 rows
          • 13PX COORDINATOR
            • 12PX SEND QC (RANDOM) :TQ1000172 rows
              • 11WINDOW CHILD PUSHED RANK72 rows
                • 10HASH GROUP BY72 rows
                  • 9PX RECEIVE72 rows
                    • 8PX SEND HASH :TQ1000072 rows
                      • 7HASH GROUP BY72 rows
                        • 6HASH JOIN3658 rows
                          • 1TABLE ACCESS FULL CR_PLAYERS72 rows
                          • 5HASH JOIN3658 rows
                            • 2TABLE ACCESS FULL CR_MATCHES16 rows
                            • 4PX BLOCK ITERATOR7551 rows
                              • 3TABLE ACCESS FULL CR_DELIVERIES7551 rows

Numbers are the order the steps run: children before their parent, top child first. Rows flow up the tree.

The DBMS_XPLAN text
-----------------------------------------------------------------------------
| Id  | Operation                      | Name          | Rows  | Cost (%CPU)|
-----------------------------------------------------------------------------
|   0 | SELECT STATEMENT               |               |     5 |    16  (25)|
|   1 |  SORT ORDER BY                 |               |     5 |    16  (25)|
|   2 |   VIEW                         |               |     5 |    15  (20)|
|   3 |    WINDOW SORT PUSHED RANK     |               |    72 |    15  (20)|
|   4 |     PX COORDINATOR             |               |       |            |
|   5 |      PX SEND QC (RANDOM)       | :TQ10001      |    72 |    15  (20)|
|   6 |       WINDOW CHILD PUSHED RANK |               |    72 |    15  (20)|
|   7 |        HASH GROUP BY           |               |    72 |    15  (20)|
|   8 |         PX RECEIVE             |               |    72 |    15  (20)|
|   9 |          PX SEND HASH          | :TQ10000      |    72 |    15  (20)|
|  10 |           HASH GROUP BY        |               |    72 |    15  (20)|
|  11 |            HASH JOIN           |               |  3658 |    12   (0)|
|  12 |             TABLE ACCESS FULL  | CR_PLAYERS    |    72 |     2   (0)|
|  13 |             HASH JOIN          |               |  3658 |    10   (0)|
|  14 |              TABLE ACCESS FULL | CR_MATCHES    |    16 |     2   (0)|
|  15 |              PX BLOCK ITERATOR |               |  7551 |     8   (0)|
|  16 |               TABLE ACCESS FULL| CR_DELIVERIES |  7551 |     8   (0)|
-----------------------------------------------------------------------------

Follow the numbers: read the 72 players into a hash table (1), read the 16 matches of 2026 into another (2), stream all 7,551 deliveries past the matches (3-5) and keep the 3,658 from 2026, stream those past the players (6), group by player in two parallel stages (7-10), keep the top 5 (11 and 14, PUSHED RANK: it never sorts all 72 players, it just keeps the best five as it goes), and sort those five (16).

The same query on 26ai

The same query on 26ai (apex.oracle.com)
  • 17SELECT STATEMENT5 rows
    • 16COUNT STOPKEY
      • 15VIEW66 rows
        • 14SORT ORDER BY STOPKEY66 rows
          • 13HASH GROUP BY66 rows
            • 12MERGE JOIN66 rows
              • 2TABLE ACCESS BY INDEX ROWID CR_PLAYERS72 rows
                • 1INDEX FULL SCAN CR_PLAYERS_PK72 rows
              • 11SORT JOIN66 rows
                • 10VIEW VW_GBC_1166 rows
                  • 9HASH GROUP BY66 rows
                    • 8HASH JOIN3658 rows
                      • 5JOIN FILTER CREATE :BF000016 rows
                        • 4TABLE ACCESS BY INDEX ROWID BATCHED CR_MATCHES16 rows
                          • 3INDEX RANGE SCAN CR_MATCHES_UK16 rows
                      • 7JOIN FILTER USE :BF00007551 rows
                        • 6TABLE ACCESS STORAGE FULL CR_DELIVERIES7551 rows

Numbers are the order the steps run: children before their parent, top child first. Rows flow up the tree.

The DBMS_XPLAN text
--------------------------------------------------------------------------------------------
| Id  | Operation                                     | Name          | Rows  | Cost (%CPU)|
--------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                              |               |     5 |    26  (31)|
|   1 |  COUNT STOPKEY                                |               |       |            |
|   2 |   VIEW                                        |               |    66 |    26  (31)|
|   3 |    SORT ORDER BY STOPKEY                      |               |    66 |    26  (31)|
|   4 |     HASH GROUP BY                             |               |    66 |    26  (31)|
|   5 |      MERGE JOIN                               |               |    66 |    22  (19)|
|   6 |       TABLE ACCESS BY INDEX ROWID             | CR_PLAYERS    |    72 |     2   (0)|
|   7 |        INDEX FULL SCAN                        | CR_PLAYERS_PK |    72 |     1   (0)|
|   8 |       SORT JOIN                               |               |    66 |    20  (20)|
|   9 |        VIEW                                   | VW_GBC_11     |    66 |    19  (16)|
|  10 |         HASH GROUP BY                         |               |    66 |    19  (16)|
|  11 |          HASH JOIN                            |               |  3658 |    18  (12)|
|  12 |           JOIN FILTER CREATE                  | :BF0000       |    16 |     2   (0)|
|  13 |            TABLE ACCESS BY INDEX ROWID BATCHED| CR_MATCHES    |    16 |     2   (0)|
|  14 |             INDEX RANGE SCAN                  | CR_MATCHES_UK |    16 |     1   (0)|
|  15 |           JOIN FILTER USE                     | :BF0000       |  7551 |    15   (7)|
|  16 |            TABLE ACCESS STORAGE FULL          | CR_DELIVERIES |  7551 |    15   (7)|
-----------------------------------------------------------------------------------------------

Same answer, very different route. The 26ai optimizer:

  • groups before joining to players (VW_GBC_11, "group-by placement"): sum the runs per batter_id first, so only 66 rows reach the players join instead of 3,658;
  • joins those 66 to the players with a merge join, reading players in key order through their primary key index (no sort needed on that side);
  • builds a Bloom filter (JOIN FILTER CREATE :BF0000) from the 2026 matches and applies it while scanning deliveries, so most 2025 balls are thrown away inside the Exadata storage layer;
  • stops at five rows (COUNT STOPKEY).

Neither plan is "the" plan: they're two optimizers' best guesses for two different databases.

The test case

Ask for each join method with a hint, and check which join operation appears in each plan.

Setup

delete from plan_table where statement_id like 'D142%';

Setup

explain plan set statement_id = 'D142_HASH' for
select /*+ leading(m d) use_hash(d) */ count(*)
from   cr_matches m join cr_deliveries d on d.match_id = m.match_id
where  m.season = 2026;

Setup

explain plan set statement_id = 'D142_NL' for
select /*+ leading(m d) use_nl(d) index(d cr_deliveries_pk) */ count(*)
from   cr_matches m join cr_deliveries d on d.match_id = m.match_id
where  m.season = 2026;

Setup

explain plan set statement_id = 'D142_MERGE' for
select /*+ leading(m d) use_merge(d) */ count(*)
from   cr_matches m join cr_deliveries d on d.match_id = m.match_id
where  m.season = 2026;

Expected result

Expected result · 3 rows
METHODHASH_JOINNESTED_LOOPSMERGE_JOIN
HASHYNN
MERGENNY
NLNYN
select substr(statement_id, 6) as method,
       max(case when operation = 'HASH JOIN'    then 'Y' else 'N' end) as hash_join,
       max(case when operation = 'NESTED LOOPS' then 'Y' else 'N' end) as nested_loops,
       max(case when operation = 'MERGE JOIN'   then 'Y' else 'N' end) as merge_join
from   plan_table
where  statement_id like 'D142%'
group  by statement_id
order  by method;

The hints: LEADING(m d) says "start from matches, then deliveries"; USE_HASH(d), USE_NL(d) and USE_MERGE(d) say how to join d in. Hints are for experiments like this one; in production code they're the last resort.

19c and 26ai

The same join methods on both; 26ai's optimizer has more transformations (group-by placement, more Bloom filters) and picks differently more often.

Gotchas

  • A NESTED LOOPS over two big inputs is the classic slow plan: usually caused by a bad row estimate ("1 row" that is really a million). Check the estimates first.
  • Hash joins need memory. When the hash table doesn't fit, it spills to temp and slows down.
  • Two NESTED LOOPS lines in a row in modern plans are normal: it's nested-loop batching, one loop for the index and one for the table.

Part 14 quiz

5 questions on all of Part 14. Get 4 right to pass. Retake it as often as you like.