“Produk apa saja yang dibeli Budi Santoso, dan berapa subtotal tiap barisnya?” — satu pertanyaan itu menyentuh empat tabel sekaligus: nama Budi ada di pelanggan, pesanannya di pesanan, isi per pesanan di pesanan_item, nama dan harga produk di produk. Query dunia nyata hampir tidak pernah cukup dengan dua tabel; laporan penjualan, rekonsiliasi tagihan, sampai dashboard operasional semuanya adalah rantai penggabungan yang panjang. Kabar baiknya: tidak ada sintaks khusus untuk banyak join. Klausa JOIN ... ON yang sudah kita kuasai cukup ditulis berurutan, dan hasil tiap join menjadi input bagi join berikutnya. Chapter ini merangkai empat tabel secara bertahap, membiasakan alias pendek yang membuat rantai terbaca, lalu membuka satu jebakan penting: jenis join yang dipilih di tengah rantai diam-diam menentukan baris mana yang bertahan di ujungnya.

Merangkai Join: Dari Dua ke Empat Tabel #

Multiple join dievaluasi dari kiri ke kanan, seperti sumbu yang memanjang: pelanggan JOIN pesanan menghasilkan tabel hasil sementara, lalu JOIN pesanan_item menempelkan baris-baris item pada tabel sementara itu, dan seterusnya. Kondisi ON tiap mata rantai cukup menghubungkan tabel baru dengan SALAH SATU tabel yang sudah ada di rantai:

Diagram di atas membaca rantainya dari kiri: pelanggan digabung ke pesanan lewat pelanggan_id, pesanan ke rincian item lewat pesanan_id, dan produk menempel pada item lewat produk_id. Skema contohnya menambahkan dua tabel baru di atas domain kepercayaan section ini — produk dan tabel detail pesanan_item:

sql buat-tabel.sql
-- Skema contoh: pelanggan -> pesanan -> pesanan_item -> produk
-- pesanan 106 = keranjang baru Dewi yang belum berisi item apa pun
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    email text NOT NULL UNIQUE
);

CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

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

CREATE TABLE pesanan_item (
    id         integer PRIMARY KEY,
    pesanan_id integer NOT NULL REFERENCES pesanan (id),
    produk_id  integer NOT NULL REFERENCES produk (id),
    jumlah     integer NOT NULL CHECK (jumlah > 0)
);

INSERT INTO pelanggan (id, nama, kota, email) VALUES
    (1, 'Budi Santoso', 'Bandung',  '[email protected]'),
    (2, 'Siti Rahayu',  'Jakarta',  '[email protected]'),
    (3, 'Andi Wijaya',  'Surabaya', '[email protected]'),
    (4, 'Dewi Lestari', 'Medan',    '[email protected]');

INSERT INTO produk (id, nama, harga) VALUES
    (1, 'Kaos Hitam',    75000.00),
    (2, 'Mug Keramik',   50000.00),
    (3, 'Topi Baseball', 60000.00),
    (4, 'Tas Kanvas',   120000.00);

INSERT INTO pesanan (id, pelanggan_id, tanggal, total) VALUES
    (101, 1,    DATE '2026-01-05', 210000.00),
    (102, 1,    DATE '2026-01-18',  50000.00),
    (103, 2,    DATE '2026-01-09', 175000.00),
    (104, 4,    DATE '2026-02-02',  60000.00),
    (105, NULL, DATE '2026-02-10',  50000.00),
    (106, 4,    DATE '2026-02-14',      0.00);

INSERT INTO pesanan_item (id, pesanan_id, produk_id, jumlah) VALUES
    (901, 101, 1, 2),
    (902, 101, 3, 1),
    (903, 102, 2, 1),
    (904, 103, 1, 1),
    (905, 103, 2, 2),
    (906, 104, 3, 1),
    (907, 105, 2, 1);

SELECT * FROM pesanan ORDER BY id;
SELECT * FROM pesanan_item ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 4
INSERT 0 6
INSERT 0 7
 id  | pelanggan_id |  tanggal   |   total   
-----+--------------+------------+-----------
 101 |            1 | 2026-01-05 | 210000.00
 102 |            1 | 2026-01-18 |  50000.00
 103 |            2 | 2026-01-09 | 175000.00
 104 |            4 | 2026-02-02 |  60000.00
 105 |              | 2026-02-10 |  50000.00
 106 |            4 | 2026-02-14 |      0.00
(6 rows)

 id  | pesanan_id | produk_id | jumlah 
-----+------------+-----------+--------
 901 |        101 |         1 |      2
 902 |        101 |         3 |      1
 903 |        102 |         2 |      1
 904 |        103 |         1 |      1
 905 |        103 |         2 |      2
 906 |        104 |         3 |      1
 907 |        105 |         2 |      1
(7 rows)

Ada tiga baris bermasalah yang sengaja dipasang sebagai alat ukur: Andi Wijaya tidak punya satu pun pesanan, pesanan 105 adalah transaksi walk-in tanpa pelanggan terdaftar, dan pesanan 106 adalah keranjang baru Dewi yang totalnya masih 0 karena belum berisi item. Bagaimana ketiganya bernasib di dalam rantai join? Bangun bertahap dan hitung:

sql progresif.sql
-- Bangun bertahap: 2 tabel, lalu 3, lalu 4; hitung baris tiap tahap
SELECT count(*) AS baris_2_tabel
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id;

SELECT count(*) AS baris_3_tabel
FROM pelanggan p
JOIN pesanan ps     ON ps.pelanggan_id = p.id
JOIN pesanan_item pi ON pi.pesanan_id  = ps.id;

SELECT count(*) AS baris_4_tabel
FROM pelanggan p
JOIN pesanan ps      ON ps.pelanggan_id = p.id
JOIN pesanan_item pi ON pi.pesanan_id   = ps.id
JOIN produk pr       ON pr.id           = pi.produk_id;

Output yang Dihasilkan:

text
 baris_2_tabel 
---------------
             5
(1 row)

 baris_3_tabel 
---------------
             6
(1 row)

 baris_4_tabel 
---------------
             6
(1 row)

Angkanya 5, lalu 6, lalu 6 lagi. Bedah nasib tiap tahap:

  1. Dua tabel: 5 baris — Budi menyumbang dua baris (punya dua pesanan), Siti dan Dewi masing-masing satu. INNER JOIN membuang Andi (tidak punya pesanan) dan pesanan 105 (tidak punya pelanggan) — perilaku yang sudah dikenal dari chapter INNER JOIN .
  2. Tiga tabel: 6 baris — item merinci pesanan: 101 punya 2 item, 102 dan 104 masing-masing 1, 103 punya 2, jadi total 6. Pesanan 106 hilang di tahap ini karena INNER JOIN ke pesanan_item tidak menemukan satu item pun padanya.
  3. Empat tabel: masih 6 baris — join ke produk TIDAK menambah baris, karena relasi item→produk arahnya banyak-ke-satu: setiap item menemukan tepat satu produk. Join hanya menambah baris bila sisi yang ditempelkan berbaris banyak — itulah mengapa pesanan (satu) ke pesanan_item (banyak) melipatgandakan, sedangkan pesanan_item (banyak) ke produk (satu) tidak. Efek penggandaan sisi banyak inilah yang dibedah tuntas di chapter Menghindari Duplikasi Hasil Join .

Laporan Lengkap Empat Tabel #

Rantai penuhnya kini tinggal ditulis satu kali, dengan kolom dari keempat tabel berdampingan:

-- Laporan lengkap: satu query menjangkau empat tabel
SELECT ps.id      AS pesanan,
       p.nama     AS pelanggan,
       pr.nama    AS produk,
       pi.jumlah  AS jumlah,
       pi.jumlah * pr.harga AS subtotal
FROM pelanggan p
JOIN pesanan ps      ON ps.pelanggan_id = p.id
JOIN pesanan_item pi ON pi.pesanan_id   = ps.id
JOIN produk pr       ON pr.id           = pi.produk_id
ORDER BY ps.id, pi.id;

Output yang Dihasilkan:

text
 pesanan |  pelanggan   |    produk     | jumlah | subtotal  
---------+--------------+---------------+--------+-----------
     101 | Budi Santoso | Kaos Hitam    |      2 | 150000.00
     101 | Budi Santoso | Topi Baseball |      1 |  60000.00
     102 | Budi Santoso | Mug Keramik   |      1 |  50000.00
     103 | Siti Rahayu  | Kaos Hitam    |      1 |  75000.00
     103 | Siti Rahayu  | Mug Keramik   |      2 | 100000.00
     104 | Dewi Lestari | Topi Baseball |      1 |  60000.00
(6 rows)

Enam baris laporan itu menjawab pertanyaan pembuka: Budi membeli Kaos Hitam dua helai (subtotal 150000) dan Topi Baseball satu (60000) lewat pesanan 101, lalu Mug Keramik lewat pesanan 102; Siti membeli Kaos Hitam dan dua Mug Keramik; Dewi membeli Topi Baseball. Mari kita bedah kebiasaan penulisannya:

  1. Alias satu-dua huruf yang konsisten — p (pelanggan), ps (pesanan), pi (pesanan item), pr (produk). Tanpa alias, ON pesanan_item.pesanan_id = pesanan.id tetap valid, tetapi rantai empat tabel dengan nama kolom lengkap akan membludak satu layar per baris.
  2. Kolom selalu dikualifikasi alias — p.nama dan pr.nama wajib dibedakan karena dua tabel sama-sama punya kolom nama; kualifikasi juga membuat pemelihara query yakin asal tiap kolom tanpa membuka skema.
  3. ORDER BY ps.id, pi.id — dua kolom penentu urutan penuh: pesanan dulu, lalu item di dalam pesanan; tanpa ini urutan baris laporan tidak dijamin.
  4. Ekspresi lintas tabel — pi.jumlah * pr.harga mengalikan kolom dari dua tabel berbeda dalam satu ekspresi; hasil join adalah satu tabel hasil gabungan, jadi seluruh operator ekspresi bekerja seperti biasa.

Urutan dan Jenis Join Menentukan Siapa yang Bertahan #

Semua rantai di atas memakai INNER JOIN, sehingga Andi, pesanan 105, dan pesanan 106 lenyap. Bagaimana bila laporan harus memantau keranjang kosong dan pelanggan diam? Tukar jenis join di mata rantai tertentu dan bandingkan dua rantai ini:

-- Rantai semua LEFT JOIN: pelanggan tanpa pesanan dan pesanan tanpa item tetap tampil
SELECT p.nama AS pelanggan, ps.id AS pesanan, pi.id AS item
FROM pelanggan p
LEFT JOIN pesanan ps      ON ps.pelanggan_id = p.id
LEFT JOIN pesanan_item pi ON pi.pesanan_id   = ps.id
ORDER BY ps.id NULLS LAST, pi.id NULLS LAST;

-- INNER JOIN di tengah rantai membatalkan perlindungan LEFT JOIN di hulunya
SELECT p.nama AS pelanggan, ps.id AS pesanan, pi.id AS item
FROM pelanggan p
LEFT JOIN pesanan ps      ON ps.pelanggan_id = p.id
JOIN pesanan_item pi      ON pi.pesanan_id   = ps.id
ORDER BY ps.id, pi.id;

Output yang Dihasilkan:

text
  pelanggan   | pesanan | item 
--------------+---------+------
 Budi Santoso |     101 |  901
 Budi Santoso |     101 |  902
 Budi Santoso |     102 |  903
 Siti Rahayu  |     103 |  904
 Siti Rahayu  |     103 |  905
 Dewi Lestari |     104 |  906
 Dewi Lestari |     106 |     
 Andi Wijaya  |         |     
(8 rows)

  pelanggan   | pesanan | item 
--------------+---------+------
 Budi Santoso |     101 |  901
 Budi Santoso |     101 |  902
 Budi Santoso |     102 |  903
 Siti Rahayu  |     103 |  904
 Siti Rahayu  |     103 |  905
 Dewi Lestari |     104 |  906
(6 rows)

Rantai pertama menghasilkan delapan baris: enam baris item yang berpasangan, satu baris Dewi dengan pesanan 106 tapi item kosong, dan satu baris Andi yang kosong di kolom pesanan sekaligus itemnya. Rantai kedua — hanya mata rantai kedua yang diganti INNER — runtuh menjadi enam baris: Andi dan pesanan 106 lenyap. Penyebabnya berjenjang: mata rantai pertama (LEFT) mempertahankan baris Andi dengan ps.id tertambal NULL, tetapi mata rantai kedua (INNER) menguji pi.pesanan_id = ps.id, dan NULL tidak pernah sama dengan apa pun — baris tambalan itu tersaring keluar, persis mekanisme filter WHERE yang membongkar LEFT JOIN di chapter LEFT JOIN . Pesanan 106 bernasib sama dari arah lain: pesanan-nya ada, tetapi tidak punya item, sehingga INNER JOIN ke pesanan_item membuangnya.

Satu catatan terakhir tentang tabel pertama: tabel paling kiri adalah jangkar rantai. Pesanan walk-in 105 tidak muncul di rantai mana pun di atas karena pesanan selalu berada di sisi kanan mata rantai pertama; untuk laporan yang berjangkar pada pesanan (misalnya daftar semua pesanan beserta pelanggannya bila ada), tulis FROM pesanan LEFT JOIN pelanggan ... dan biarkan rantainya mengalir ke kanan dari sana.

Rantai join menjangkau jauh, tetapi setiap mata rantai tetap menggabungkan SELURUH baris pasangan yang cocok. Kadang yang dibutuhkan jauh lebih selektif: dari semua pesanan tiap pelanggan, ambil satu yang terbaru saja. Subquery LIMIT 1 biasa tidak bisa melakukannya karena subquery di FROM tidak melihat baris pelanggan yang sedang diproses — dan di titik itu PostgreSQL membuka pintunya lewat kata kunci LATERAL, bintang chapter berikutnya.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- Skema contoh: pelanggan -> pesanan -> pesanan_item -> produk
-- pesanan 106 = keranjang baru Dewi yang belum berisi item apa pun
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    email text NOT NULL UNIQUE
);

CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

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

CREATE TABLE pesanan_item (
    id         integer PRIMARY KEY,
    pesanan_id integer NOT NULL REFERENCES pesanan (id),
    produk_id  integer NOT NULL REFERENCES produk (id),
    jumlah     integer NOT NULL CHECK (jumlah > 0)
);

INSERT INTO pelanggan (id, nama, kota, email) VALUES
    (1, 'Budi Santoso', 'Bandung',  '[email protected]'),
    (2, 'Siti Rahayu',  'Jakarta',  '[email protected]'),
    (3, 'Andi Wijaya',  'Surabaya', '[email protected]'),
    (4, 'Dewi Lestari', 'Medan',    '[email protected]');

INSERT INTO produk (id, nama, harga) VALUES
    (1, 'Kaos Hitam',    75000.00),
    (2, 'Mug Keramik',   50000.00),
    (3, 'Topi Baseball', 60000.00),
    (4, 'Tas Kanvas',   120000.00);

INSERT INTO pesanan (id, pelanggan_id, tanggal, total) VALUES
    (101, 1,    DATE '2026-01-05', 210000.00),
    (102, 1,    DATE '2026-01-18',  50000.00),
    (103, 2,    DATE '2026-01-09', 175000.00),
    (104, 4,    DATE '2026-02-02',  60000.00),
    (105, NULL, DATE '2026-02-10',  50000.00),
    (106, 4,    DATE '2026-02-14',      0.00);

INSERT INTO pesanan_item (id, pesanan_id, produk_id, jumlah) VALUES
    (901, 101, 1, 2),
    (902, 101, 3, 1),
    (903, 102, 2, 1),
    (904, 103, 1, 1),
    (905, 103, 2, 2),
    (906, 104, 3, 1),
    (907, 105, 2, 1);

SELECT * FROM pesanan ORDER BY id;
SELECT * FROM pesanan_item ORDER BY id;

laporan-lengkap.sql

Unduh berkas
sql
-- Laporan lengkap: satu query menjangkau empat tabel
SELECT ps.id      AS pesanan,
       p.nama     AS pelanggan,
       pr.nama    AS produk,
       pi.jumlah  AS jumlah,
       pi.jumlah * pr.harga AS subtotal
FROM pelanggan p
JOIN pesanan ps      ON ps.pelanggan_id = p.id
JOIN pesanan_item pi ON pi.pesanan_id   = ps.id
JOIN produk pr       ON pr.id           = pi.produk_id
ORDER BY ps.id, pi.id;

progresif.sql

Unduh berkas
sql
-- Bangun bertahap: 2 tabel, lalu 3, lalu 4; hitung baris tiap tahap
SELECT count(*) AS baris_2_tabel
FROM pelanggan p
JOIN pesanan ps ON ps.pelanggan_id = p.id;

SELECT count(*) AS baris_3_tabel
FROM pelanggan p
JOIN pesanan ps     ON ps.pelanggan_id = p.id
JOIN pesanan_item pi ON pi.pesanan_id  = ps.id;

SELECT count(*) AS baris_4_tabel
FROM pelanggan p
JOIN pesanan ps      ON ps.pelanggan_id = p.id
JOIN pesanan_item pi ON pi.pesanan_id   = ps.id
JOIN produk pr       ON pr.id           = pi.produk_id;

urutan-penting.sql

Unduh berkas
sql
-- Rantai semua LEFT JOIN: pelanggan tanpa pesanan dan pesanan tanpa item tetap tampil
SELECT p.nama AS pelanggan, ps.id AS pesanan, pi.id AS item
FROM pelanggan p
LEFT JOIN pesanan ps      ON ps.pelanggan_id = p.id
LEFT JOIN pesanan_item pi ON pi.pesanan_id   = ps.id
ORDER BY ps.id NULLS LAST, pi.id NULLS LAST;

-- INNER JOIN di tengah rantai membatalkan perlindungan LEFT JOIN di hulunya
SELECT p.nama AS pelanggan, ps.id AS pesanan, pi.id AS item
FROM pelanggan p
LEFT JOIN pesanan ps      ON ps.pelanggan_id = p.id
JOIN pesanan_item pi      ON pi.pesanan_id   = ps.id
ORDER BY ps.id, pi.id;