Sepanjang section ini kita menulis JOIN ... ON dengan kondisi eksplisit, dan sebuah bentuk yang jauh lebih singkat terasa menggoda: pelanggan NATURAL JOIN daftar_email — tanpa ON, tanpa USING, tanpa satu kondisi pun. NATURAL JOIN meminta PostgreSQL menebak sendiri kunci penggabungannya dari nama-nama kolom yang sama di kedua tabel. Saat kolomnya memang cocok, hasilnya rapi: lebih pendek dari USING, jauh lebih pendek dari ON. Masalahnya bukan hari ini — masalahnya enam bulan dari sekarang, ketika seorang pengembang lain menambahkan satu kolom ke salah satu tabel dan query yang “sudah jalan setahun” diam-diam menggabungkan dengan makna yang berbeda. Chapter ini membedah cara kerja NATURAL JOIN, membandingkannya dengan USING dan ON, lalu mendemonstrasikan dua jebakannya secara langsung — termasuk satu skenario evolusi skema yang mengubah hasil join tanpa satu error pun.

Cara Kerja: Kolom Senama Menjadi Kunci #

NATURAL JOIN membaca kedua tabel, mengumpulkan semua nama kolom yang muncul di KEDUANYA, lalu menggabungkan baris yang nilainya sama pada seluruh kolom senama itu. Perilakunya identik dengan USING yang daftar kolomnya ditulis otomatis: kolom penggabungan tampil sekali di hasil, dan baris tanpa pasangan dibuang (perilaku INNER JOIN). Skema contoh mempertemukan pelanggan dengan daftar_email — pasangan yang sudah dipakai untuk rekonsiliasi di chapter FULL OUTER JOIN — di mana satu-satunya kolom senama adalah email:

sql buat-tabel.sql
-- Skema contoh: pelanggan, pesanan, dan daftar_email dari chapter FULL OUTER JOIN
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    email text NOT NULL UNIQUE
);

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 daftar_email (
    email  text PRIMARY KEY,
    status text NOT NULL
);

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 pesanan (id, pelanggan_id, tanggal, total) VALUES
    (101, 1,    DATE '2026-01-05', 150000.00),
    (102, 1,    DATE '2026-01-18',  75000.00),
    (103, 2,    DATE '2026-01-09', 220000.00),
    (104, 4,    DATE '2026-02-02',  50000.00),
    (105, NULL, DATE '2026-02-10',  30000.00);

INSERT INTO daftar_email (email, status) VALUES
    ('[email protected]', 'terverifikasi'),
    ('[email protected]', 'terverifikasi'),
    ('[email protected]', 'pending'),
    ('[email protected]', 'pending');

SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM daftar_email ORDER BY email;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
INSERT 0 4
 id |     nama     |   kota   |      email       
----+--------------+----------+------------------
  1 | Budi Santoso | Bandung  | [email protected]
  2 | Siti Rahayu  | Jakarta  | [email protected]
  3 | Andi Wijaya  | Surabaya | [email protected]
  4 | Dewi Lestari | Medan    | [email protected]
(4 rows)

       email       |    status     
-------------------+---------------
 [email protected]  | terverifikasi
 [email protected] | pending
 [email protected]  | terverifikasi
 [email protected] | pending
(4 rows)

Dengan skema sejalan seperti ini, NATURAL JOIN memang enak dipakai:

-- NATURAL JOIN tanpa klausa ON: kolom senama (email) menjadi kunci penggabungan
SELECT p.nama, d.status
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

-- SELECT *: kolom penggabungan tampil SEKALI dan berada paling depan
SELECT *
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

Output yang Dihasilkan:

text
     nama     |    status     
--------------+---------------
 Budi Santoso | terverifikasi
 Siti Rahayu  | terverifikasi
(2 rows)

      email       | id |     nama     |  kota   |    status     
------------------+----+--------------+---------+---------------
 [email protected] |  1 | Budi Santoso | Bandung | terverifikasi
 [email protected] |  2 | Siti Rahayu  | Jakarta | terverifikasi
(2 rows)

Query pertama menghasilkan dua baris: Budi dan Siti — pemilik email yang terdaftar di daftar_email; Ratna dan Yusuf tidak punya akun pelanggan, sedangkan Andi dan Dewi belum masuk milis. Query kedua memperlihatkan ciri bentuk keluarannya: kolom email tampil sekali (digabungkan, seperti perilaku USING dari chapter INNER JOIN ) dan posisinya paling depan, diikuti kolom-kolom pelanggan lalu kolom status milik daftar_email. Alias p dan d tetap dipakai agar kolom-kolom lain bisa dikualifikasi — p.nama dan d.status — karena tanpa kualifikasi, nama kolom yang senama tetap ambigu.

NATURAL, USING, dan ON Berdampingan #

Untuk maksud yang sama, ketiga gaya penulisan menghasilkan keluaran yang identik pada skema saat ini:

-- Tiga gaya untuk maksud yang sama: gabungkan pelanggan dan daftar_email pada email
SELECT p.nama, d.status
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

SELECT p.nama, d.status
FROM pelanggan p JOIN daftar_email d USING (email)
ORDER BY p.id;

SELECT p.nama, d.status
FROM pelanggan p JOIN daftar_email d ON d.email = p.email
ORDER BY p.id;

Output yang Dihasilkan:

text
     nama     |    status     
--------------+---------------
 Budi Santoso | terverifikasi
 Siti Rahayu  | terverifikasi
(2 rows)

     nama     |    status     
--------------+---------------
 Budi Santoso | terverifikasi
 Siti Rahayu  | terverifikasi
(2 rows)

     nama     |    status     
--------------+---------------
 Budi Santoso | terverifikasi
 Siti Rahayu  | terverifikasi
(2 rows)

Ketiga query mengembalikan dua baris yang persis sama. Pembeda ketiganya bukan hasilnya hari ini, melainkan siapa yang memegang kendali atas kunci penggabungan:

AspekNATURAL JOINJOIN ... USING (email)JOIN ... ON d.email = p.email
Kunci ditentukan olehnama kolom senama, otomatisAnda, tapi harus senamaAnda, bebas nama kolom
Bila skema berubahkunci ikut berubah diam-diamtetap email (error bila kolom hilang)tetap benar selama kolom ada
Kolom berbeda nama (id vs pelanggan_id)tidak bisa dipakaitidak bisa dipakaisatu-satunya yang bisa
Kesesuaian untuk kode produksirendahbaik untuk kunci senamapaling umum dan paling fleksibel

Baris terakhir tabel itu bukan basa-basi: seluruh relasi PK–FK pada section ini (pelanggan.id ↔ pesanan.pelanggan_id) memakai nama kolom yang BERBEDA secara sengaja — nama yang menjelaskan perannya. Untuk relasi seperti itu, NATURAL dan USING sama-sama tidak berdaya; hanya ON yang bisa menyatakan ps.pelanggan_id = p.id.

Jebakan Pertama: Kolom Senama yang Maknanya Berbeda #

NATURAL JOIN tidak memilih “kolom yang tepat” — ia mengambil SEMUA kolom senama, apapun maknanya. Ujilah dengan mempertemukan pelanggan dan pesanan:

sql natural-id.sql
-- Kolom senama yang maknanya berbeda: id pelanggan (1-4) vs id pesanan (101-105)
SELECT p.nama, ps.id AS pesanan
FROM pelanggan p NATURAL JOIN pesanan ps
ORDER BY p.nama, ps.id;

-- Perbaikannya: nyatakan kunci yang benar secara eksplisit
SELECT p.nama, ps.id AS pesanan
FROM pelanggan p JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.nama, ps.id;

Output yang Dihasilkan:

text
 nama | pesanan 
------+---------
(0 rows)

     nama     | pesanan 
--------------+---------
 Budi Santoso |     101
 Budi Santoso |     102
 Dewi Lestari |     104
 Siti Rahayu  |     103
(4 rows)

Query NATURAL menghasilkan NOL baris. Penyebabnya: satu-satunya kolom senama kedua tabel adalah id, jadi PostgreSQL menggabungkan pelanggan.id = pesanan.id — memasangkan nomor pelanggan 1–4 dengan nomor pesanan 101–105 yang tidak pernah bertumpang tindih. Dua kolom kebetulan bernama sama tetapi memaknai hal yang berbeda (identitas pelanggan versus identitas pesanan), dan NATURAL JOIN tidak punya cara mengetahuinya. Query kedua menyatakan kunci yang benar — ps.pelanggan_id = p.id — dan mengembalikan empat baris yang masuk akal. Perhatikan juga bahwa nasib buruk query pertama masih tergolong beruntung: bila nomor pesanan kebetulan di rentang 1–4, query NATURAL akan mengembalikan pasangan-pasangan SALAH tanpa indikasi apa pun — jauh lebih buruk daripada hasil kosong.

Sebaliknya, bila TIDAK ada satu pun kolom senama, NATURAL JOIN tidak menolak query — PostgreSQL memperlakukannya seperti CROSS JOIN. Terverifikasi langsung pada PostgreSQL 17: dua tabel dua baris tanpa kolom senama menghasilkan empat baris kombinasi. Perilaku ini konsisten dengan definisinya — USING dengan daftar kosong — dan menambah satu jalan lagi menuju bencana kartesian yang dibahas di chapter CROSS JOIN .

Jebakan Kedua: Skema Berubah, Join Ikut Berubah Diam-diam #

Jebakan pertama masih bisa dicegah dengan review query yang teliti. Jebakan kedua lebih licik: query yang hari ini BENAR berubah salah besok tanpa disentuh. Skenarionya klasik — bulan keenam proyek, tim menambahkan kolom status keanggotaan pada pelanggan:

-- Enam bulan kemudian: kolom status ditambahkan ke pelanggan
ALTER TABLE pelanggan ADD COLUMN status text;
UPDATE pelanggan SET status = 'aktif'    WHERE id IN (1, 2, 4);
UPDATE pelanggan SET status = 'nonaktif' WHERE id = 3;

-- NATURAL JOIN kini menggabungkan pada email DAN status: hasil melompat jadi 0 baris
SELECT p.nama, d.status AS status_email
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

-- Query yang sama dengan kunci eksplisit: hasil tetap benar 2 baris
SELECT p.nama, d.status AS status_email
FROM pelanggan p JOIN daftar_email d ON d.email = p.email
ORDER BY p.id;

Output yang Dihasilkan:

text
ALTER TABLE
UPDATE 3
UPDATE 1
 nama | status_email 
------+--------------
(0 rows)

     nama     | status_email  
--------------+---------------
 Budi Santoso | terverifikasi
 Siti Rahayu  | terverifikasi
(2 rows)

ALTER TABLE dan dua UPDATE tidak menyentuh query mana pun — tetapi query NATURAL yang semula mengembalikan dua baris kini mengembalikan NOL. Kolom status kini senama di kedua tabel, jadi kondisi penggabungan berubah menjadi email sama DAN status sama; status pelanggan (aktif) tidak pernah sama dengan status milis (terverifikasi), sehingga tidak ada pasangan yang lolos. Tidak ada error, tidak ada peringatan — laporan cukup tampil kosong. Query pembanding dengan ON d.email = p.email tidak peduli kolom baru apa pun dan tetap mengembalikan dua baris yang benar. Bayangkan variasi yang lebih samar dari skenario ini: kolom created_at atau updated_at yang ditambahkan otomatis oleh ORM di kedua tabel, atau kolom nama yang tiba-tiba muncul di tabel anak — pasangan yang nilai sama hanya pada sebagian baris membuat hasil join menyusut SEBAGIAN, dan penyusutan parsial jauh lebih sulit ditangkap daripada hasil kosong total.

Kapan NATURAL JOIN Masih Masuk Akal #

Ranah yang tersisa untuk NATURAL JOIN cukup sempit: eksplorasi ad-hoc di sesi psql, di atas tabel yang Anda kenal betul dan tidak sedang berevolusi. Dengan psql, join cepat dua tabel kecil untuk melihat bentuk datanya memang lebih nyaman ditulis pendek. Begitu query pindah ke file .sql di repositori — kode yang akan dibaca, diubah, dan diandalkan orang lain — kendali eksplisit menjadi tidak ternegosiasikan: satu baris ON menyatakan niat, tahan terhadap perubahan skema, dan bisa direview.

Pola besarnya kini lengkap: sepuluh bentuk join telah menuntun kita dari penggabungan dua tabel paling sederhana hingga subquery per-baris. Satu musuh terakhir masih bersembunyi di balik semua jenis join — bukan kesalahan pasangan, melainkan pasangan yang TERLALU BANYAK: satu pelanggan bertemu lima pesanan dan laporan Anda menghitungnya lima kali. Chapter penutup section ini membedah duplikasi hasil join beserta obatnya.

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, dan daftar_email dari chapter FULL OUTER JOIN
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    email text NOT NULL UNIQUE
);

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 daftar_email (
    email  text PRIMARY KEY,
    status text NOT NULL
);

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 pesanan (id, pelanggan_id, tanggal, total) VALUES
    (101, 1,    DATE '2026-01-05', 150000.00),
    (102, 1,    DATE '2026-01-18',  75000.00),
    (103, 2,    DATE '2026-01-09', 220000.00),
    (104, 4,    DATE '2026-02-02',  50000.00),
    (105, NULL, DATE '2026-02-10',  30000.00);

INSERT INTO daftar_email (email, status) VALUES
    ('[email protected]', 'terverifikasi'),
    ('[email protected]', 'terverifikasi'),
    ('[email protected]', 'pending'),
    ('[email protected]', 'pending');

SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM daftar_email ORDER BY email;

jebakan-status.sql

Unduh berkas
sql
-- Enam bulan kemudian: kolom status ditambahkan ke pelanggan
ALTER TABLE pelanggan ADD COLUMN status text;
UPDATE pelanggan SET status = 'aktif'    WHERE id IN (1, 2, 4);
UPDATE pelanggan SET status = 'nonaktif' WHERE id = 3;

-- NATURAL JOIN kini menggabungkan pada email DAN status: hasil melompat jadi 0 baris
SELECT p.nama, d.status AS status_email
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

-- Query yang sama dengan kunci eksplisit: hasil tetap benar 2 baris
SELECT p.nama, d.status AS status_email
FROM pelanggan p JOIN daftar_email d ON d.email = p.email
ORDER BY p.id;

natural-dasar.sql

Unduh berkas
sql
-- NATURAL JOIN tanpa klausa ON: kolom senama (email) menjadi kunci penggabungan
SELECT p.nama, d.status
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

-- SELECT *: kolom penggabungan tampil SEKALI dan berada paling depan
SELECT *
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

natural-id.sql

Unduh berkas
sql
-- Kolom senama yang maknanya berbeda: id pelanggan (1-4) vs id pesanan (101-105)
SELECT p.nama, ps.id AS pesanan
FROM pelanggan p NATURAL JOIN pesanan ps
ORDER BY p.nama, ps.id;

-- Perbaikannya: nyatakan kunci yang benar secara eksplisit
SELECT p.nama, ps.id AS pesanan
FROM pelanggan p JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.nama, ps.id;

perbandingan.sql

Unduh berkas
sql
-- Tiga gaya untuk maksud yang sama: gabungkan pelanggan dan daftar_email pada email
SELECT p.nama, d.status
FROM pelanggan p NATURAL JOIN daftar_email d
ORDER BY p.id;

SELECT p.nama, d.status
FROM pelanggan p JOIN daftar_email d USING (email)
ORDER BY p.id;

SELECT p.nama, d.status
FROM pelanggan p JOIN daftar_email d ON d.email = p.email
ORDER BY p.id;