Query dunia nyata jarang berhenti di satu tabel. Laporan penjualan menggabungkan order dengan pelanggan, pelanggan dengan kota, dan seterusnya — dan setiap penggabungan bisa dikerjakan dengan cara yang sama sekali berbeda. PostgreSQL mengenal tiga strategi join: Nested Loop, Hash Join, dan Merge Join. Ketiganya menghasilkan baris yang sama, tetapi karakter biayanya bertolak belakang: satu juara di angka ribuan baris namun celaka di jutaan, satu lagi sebaliknya. Membaca nama strategi di baris pertama EXPLAIN adalah keterampilan diagnosa yang setua PostgreSQL sendiri. Chapter ini membedah ketiganya pada domain yang sama — 5.000 pelanggan dan 30.000 order Toko Maju Jaya — lalu memaksa planner memperlihatkan harganya.

sql buat-tabel.sql
-- Domain join: 5.000 pelanggan dan 30.000 order dengan index pada kolom kunci.
CREATE TABLE pelanggan (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kota     text NOT NULL
);

INSERT INTO pelanggan (id, nama, kota)
SELECT g,
       'Pelanggan ' || lpad(g::text, 4, '0'),
       (ARRAY['Jakarta','Bandung','Surabaya','Medan','Denpasar'])[1 + (g - 1) / 1000]
FROM generate_series(1, 5000) AS g;

ANALYZE pelanggan;

CREATE TABLE order_penjualan (
    id           bigint PRIMARY KEY,
    pelanggan_id integer NOT NULL REFERENCES pelanggan(id),
    tanggal      date NOT NULL,
    total        numeric(12,2) NOT NULL
);

INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total)
SELECT g,
       1 + (g * 37) % 5000,
       DATE '2026-01-01' + (g % 180),
       ((g % 900) * 1000 + 25000)::numeric(12,2)
FROM generate_series(1, 30000) AS g;

CREATE INDEX idx_order_pelanggan ON order_penjualan (pelanggan_id);

ANALYZE order_penjualan;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 5000
ANALYZE
CREATE TABLE
INSERT 0 30000
CREATE INDEX
ANALYZE

Nested Loop: Satu Baris Luar, Satu Pencarian Dalam #

Nested Loop adalah strategi paling sederhana: ambil satu baris dari sisi luar (outer), cari pasangannya di sisi dalam (inner) lewat index, keluarkan hasilnya, lalu ulangi untuk baris luar berikutnya. Kasus idealnya terlihat pada query “seluruh order milik satu pelanggan”:

sql nested-loop.sql
-- Nested Loop untuk kasus paling selektif: seluruh order milik satu pelanggan.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT p.nama, o.tanggal, o.total
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
WHERE o.pelanggan_id = 777;

Output yang Dihasilkan:

text
                                       QUERY PLAN                                        
-----------------------------------------------------------------------------------------
 Nested Loop  (cost=4.62..33.59 rows=6 width=25)
   ->  Index Scan using pelanggan_pkey on pelanggan p  (cost=0.28..8.30 rows=1 width=19)
         Index Cond: (id = 777)
   ->  Bitmap Heap Scan on order_penjualan o  (cost=4.33..25.23 rows=6 width=14)
         Recheck Cond: (pelanggan_id = 777)
         ->  Bitmap Index Scan on idx_order_pelanggan (cost=0.00..4.33 rows=6 width=0)
               Index Cond: (pelanggan_id = 777)
(7 rows)

Mari kita bedah rencana itu:

  1. Sisi luar adalah Index Scan using pelanggan_pkey — satu pelanggan ditemukan lewat primary key, satu baris, biaya 8.30.
  2. Sisi dalam adalah Bitmap Heap Scan pada order_penjualan dengan Index Cond: (pelanggan_id = 777) — pencarian enam order lewat index, dijalankan sekali karena sisi luar hanya berisi satu baris.
  3. Biaya total 33.59 kira-kira mengikuti pola biaya luar + baris luar × biaya pencarian dalam. Dengan satu baris luar, perkalian itu hampir tak terasa.

Dari pola itu terbaca syarat kemenangan Nested Loop: sisi luar kecil dan sisi dalam terindeks pada kolom kunci join. Satu kelebihan yang hanya dimiliki strategi ini: kondisi join tidak harus kesetaraan. ON o.tanggal > p.tanggal_daftar — perbandingan rentang antar tabel — hanya bisa dilayani Nested Loop.

Hash Join: Bangun Tabel Hash, Lalu Probe #

Hash Join mengubah masalah pencarian pasangan menjadi pencarian kamus. Sisi kecil dibaca sekali, setiap baris di-hash-kan kolom kuncinya, lalu disimpan ke tabel hash di memori. Sisi besar kemudian dibaca sekali; setiap baris menghitung hash kuncinya dan langsung menemukan pasangan di kamus itu. Dua tabel dibaca masing-masing hanya sekali — tidak ada pencarian berulang:

sql hash-join.sql
-- Hash Join untuk join besar tanpa filter: hash dibangun dari sisi kecil
-- (pelanggan), lalu setiap order mem-probe tabel hash tersebut.
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, SUMMARY OFF)
SELECT p.kota, count(*) AS jumlah_order, sum(o.total) AS nilai
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota;

Output yang Dihasilkan:

text
                              QUERY PLAN                               
-----------------------------------------------------------------------
 HashAggregate (actual rows=5 loops=1)
   Group Key: p.kota
   Batches: 1  Memory Usage: 24kB
   ->  Hash Join (actual rows=30000 loops=1)
         Hash Cond: (o.pelanggan_id = p.id)
         ->  Seq Scan on order_penjualan o (actual rows=30000 loops=1)
         ->  Hash (actual rows=5000 loops=1)
               Buckets: 8192  Batches: 1  Memory Usage: 287kB
               ->  Seq Scan on pelanggan p (actual rows=5000 loops=1)
(9 rows)

Node Hash memuat fakta internalnya: Buckets: 8192 adalah jumlah wadah tabel hash, Batches: 1 menyatakan semuanya muat di memori, dan Memory Usage: 287kB adalah memori work_mem yang terpakai untuk membangun hash dari 5.000 pelanggan. Angka Batches yang lebih besar dari satu berarti tabel hash tidak muat dan sebagian datanya ditumpahkan ke file sementara di disk — join tetap benar, tetapi biayanya melonjak. Karena pencocokan dilakukan lewat fungsi hash, Hash Join mensyaratkan kondisi join berupa kesetaraan (=).

Merge Join: Dua Aliran Terurut #

Merge Join bekerja seperti meritsu dua tumpukan kartu yang sudah terurut: maju bergantian, cocokkan kunci yang sama, keluarkan pasangannya. Syaratnya tegas — kedua sisi harus datang dalam keadaan terurut pada kolom kunci. Beruntung, index B-tree memang menyajikan baris secara terurut, sehingga kedua syarat bisa dipenuhi gratis. Query agregat yang tadi dilayani Hash Join bisa kita paksa beralih ke Merge Join:

sql merge-join.sql
-- Query yang sama, dua strategi: Hash Join (pilihan natural) versus
-- Merge Join (dipaksa dengan mematikan dua strategi lain).
-- Kedua sisi Merge Join sudah terurut lewat index, tanpa node Sort.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT p.kota, count(*) AS jumlah_order, sum(o.total) AS nilai
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota;

SET enable_hashjoin = off;
SET enable_nestloop = off;

EXPLAIN
SELECT p.kota, count(*) AS jumlah_order, sum(o.total) AS nilai
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota;

Output yang Dihasilkan:

text
                                     QUERY PLAN                                     
------------------------------------------------------------------------------------
 HashAggregate  (cost=945.32..945.38 rows=5 width=48)
   Group Key: p.kota
   ->  Hash Join  (cost=149.50..720.32 rows=30000 width=14)
         Hash Cond: (o.pelanggan_id = p.id)
         ->  Seq Scan on order_penjualan o  (cost=0.00..492.00 rows=30000 width=10)
         ->  Hash  (cost=87.00..87.00 rows=5000 width=12)
               ->  Seq Scan on pelanggan p  (cost=0.00..87.00 rows=5000 width=12)
(7 rows)

SET
SET
                                                   QUERY PLAN                                                     
-----------------------------------------------------------------------------------------------------------------
 HashAggregate  (cost=2182.06..2182.12 rows=5 width=48)
   Group Key: p.kota
   ->  Merge Join  (cost=0.57..1957.06 rows=30000 width=14)
         Merge Cond: (o.pelanggan_id = p.id)
         ->  Index Scan using idx_order_pelanggan on order_penjualan o  (cost=0.29..1390.28 rows=30000 width=10)
         ->  Index Scan using pelanggan_pkey on pelanggan p  (cost=0.28..179.28 rows=5000 width=12)
(6 rows)

Perhatikan dua hal pada rencana Merge Join. Pertama, tidak ada node Sort — urutan datang dari Index Scan pada kedua sisi, itulah keunggulan Merge Join di tabel besar yang index-nya pas. Kedua, biayanya (±1.957) kalah dari Hash Join (±720) pada data ini: membaca kedua index dari awal sampai akhir ternyata lebih mahal daripada membangun hash kecil. Merge Join menang ketika kedua input memang sudah terurut dan berukuran besar, atau ketika hasil join sendiri paling baik dihasilkan dalam keadaan terurut. Seperti Hash Join, syaratnya juga kesetaraan.

Strategi yang Sama, Biaya yang Berbeda #

Rasa manis Nested Loop tadi hilang begitu sisi luar membesar. Query berikut menyaring pelanggan dari satu kota — 1.000 pelanggan, 6.000 order hasil join. Kita jalankan dua kali: pilihan natural, lalu Nested Loop yang dipaksa:

-- Query yang sama, dua strategi berbeda: pilihan natural (Hash Join)
-- versus Nested Loop yang dipaksa. Perhatikan loops pada sisi dalam.
-- Angka biaya dan waktu dapat berbeda di mesin Anda.
EXPLAIN
SELECT p.nama, o.tanggal, o.total
FROM pelanggan p
JOIN order_penjualan o ON o.pelanggan_id = p.id
WHERE p.kota = 'Medan';

SET enable_hashjoin = off;
SET enable_mergejoin = off;

EXPLAIN ANALYZE
SELECT p.nama, o.tanggal, o.total
FROM pelanggan p
JOIN order_penjualan o ON o.pelanggan_id = p.id
WHERE p.kota = 'Medan';

Output yang Dihasilkan:

text
                                  QUERY PLAN                                  
------------------------------------------------------------------------------
 Hash Join  (cost=112.00..682.82 rows=6000 width=25)
   Hash Cond: (o.pelanggan_id = p.id)
   ->  Seq Scan on order_penjualan o  (cost=0.00..492.00 rows=30000 width=14)
   ->  Hash  (cost=99.50..99.50 rows=1000 width=19)
         ->  Seq Scan on pelanggan p  (cost=0.00..99.50 rows=1000 width=19)
               Filter: (kota = 'Medan'::text)
(6 rows)

SET
SET
                                                                   QUERY PLAN                                                                    
-------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=0.29..1492.00 rows=6000 width=25) (actual time=0.294..6.434 rows=6000 loops=1)
   ->  Seq Scan on pelanggan p  (cost=0.00..99.50 rows=1000 width=19) (actual time=0.265..0.529 rows=1000 loops=1)
         Filter: (kota = 'Medan'::text)
         Rows Removed by Filter: 4000
   ->  Index Scan using idx_order_pelanggan on order_penjualan o  (cost=0.29..1.33 rows=6 width=14) (actual time=0.002..0.005 rows=6 loops=1000)
         Index Cond: (pelanggan_id = p.id)
 Planning Time: 0.185 ms
 Execution Time: 6.743 ms
(8 rows)

Hash Join mengalahkan Nested Loop hampir dua kali lipat (682 vs 1492) pada query yang sama. Baris terakhir sisi dalam menjelaskan mengapa: Index Scan ... (actual time=0.002..0.005 rows=6 loops=1000). Kata kunci loops=1000 — pencarian index dijalankan seribu kali, sekali per pelanggan Medan; rows=6 adalah rata-rata hasil per perulangan, jadi total baris dalam adalah 6 × 1000 = 6.000. Seribu penurunan pohon index kecil-kecilan kalah dari satu hash build plus satu kali baca berurutan.

Kapan Masing-Masing Menang #

StrategiMekanismeJuara ketikaHindari ketika
Nested LoopTiap baris luar memicu satu pencarian index di sisi dalamSisi luar kecil (puluhan-baris), sisi dalam terindeks; kondisi join non-kesetaraanSisi luar besar — pencarian dalam berlipat ribuan kali
Hash JoinHash sisi kecil di memori, sisi besar di-probe sekaliJoin kesetaraan berukuran menengah-besar tanpa index yang cocokwork_mem kecil sehingga hash tumpah ke disk (Batches > 1)
Merge JoinDua aliran terurut digabung sekali jalanKedua input besar dan sudah terurut (index atau sort); hasil yang diinginkan terurutInput harus di-sort dulu — biaya sort menambah dua node ekstra

Urutan Join di Tangan Planner #

Urutan tabel pada teks SQL tidak mengikat urutan pengerjaan. Untuk INNER JOIN, planner bebas menukar sisi mana pun menjadi sisi luar; pada rencana Nested Loop di atas, filter kota = 'Medan' membuat sisi pelanggan menjadi luar karena hanya 1.000 barisnya yang lolos. Kebebasan ini tidak berlaku penuh untuk OUTER JOIN — sisi yang mempertahankan baris tanpa pasangan tidak bisa ditukar tanpa mengubah makna query. Bila kita sengaja ingin mengunci urutan, pagar yang tersedia berupa subquery LATERAL atau CTE dengan MATERIALIZED — keduanya memaksa planner mengeksekusi blok tertentu lebih dulu.

Tiga strategi, satu pemilih: siapa yang memutuskan Hash versus Nested Loop pada query Anda? Jawabannya adalah query planner — mesin penaksir biaya yang menjadi pokok chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- Domain join: 5.000 pelanggan dan 30.000 order dengan index pada kolom kunci.
CREATE TABLE pelanggan (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kota     text NOT NULL
);

INSERT INTO pelanggan (id, nama, kota)
SELECT g,
       'Pelanggan ' || lpad(g::text, 4, '0'),
       (ARRAY['Jakarta','Bandung','Surabaya','Medan','Denpasar'])[1 + (g - 1) / 1000]
FROM generate_series(1, 5000) AS g;

ANALYZE pelanggan;

CREATE TABLE order_penjualan (
    id           bigint PRIMARY KEY,
    pelanggan_id integer NOT NULL REFERENCES pelanggan(id),
    tanggal      date NOT NULL,
    total        numeric(12,2) NOT NULL
);

INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total)
SELECT g,
       1 + (g * 37) % 5000,
       DATE '2026-01-01' + (g % 180),
       ((g % 900) * 1000 + 25000)::numeric(12,2)
FROM generate_series(1, 30000) AS g;

CREATE INDEX idx_order_pelanggan ON order_penjualan (pelanggan_id);

ANALYZE order_penjualan;

hash-join.sql

Unduh berkas
sql
-- Hash Join untuk join besar tanpa filter: hash dibangun dari sisi kecil
-- (pelanggan), lalu setiap order mem-probe tabel hash tersebut.
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, SUMMARY OFF)
SELECT p.kota, count(*) AS jumlah_order, sum(o.total) AS nilai
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota;

merge-join.sql

Unduh berkas
sql
-- Query yang sama, dua strategi: Hash Join (pilihan natural) versus
-- Merge Join (dipaksa dengan mematikan dua strategi lain).
-- Kedua sisi Merge Join sudah terurut lewat index, tanpa node Sort.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT p.kota, count(*) AS jumlah_order, sum(o.total) AS nilai
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota;

SET enable_hashjoin = off;
SET enable_nestloop = off;

EXPLAIN
SELECT p.kota, count(*) AS jumlah_order, sum(o.total) AS nilai
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota;

nested-loop.sql

Unduh berkas
sql
-- Nested Loop untuk kasus paling selektif: seluruh order milik satu pelanggan.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT p.nama, o.tanggal, o.total
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
WHERE o.pelanggan_id = 777;

perbandingan-biaya.sql

Unduh berkas
sql
-- Query yang sama, dua strategi berbeda: pilihan natural (Hash Join)
-- versus Nested Loop yang dipaksa. Perhatikan loops pada sisi dalam.
-- Angka biaya dan waktu dapat berbeda di mesin Anda.
EXPLAIN
SELECT p.nama, o.tanggal, o.total
FROM pelanggan p
JOIN order_penjualan o ON o.pelanggan_id = p.id
WHERE p.kota = 'Medan';

SET enable_hashjoin = off;
SET enable_mergejoin = off;

EXPLAIN ANALYZE
SELECT p.nama, o.tanggal, o.total
FROM pelanggan p
JOIN order_penjualan o ON o.pelanggan_id = p.id
WHERE p.kota = 'Medan';