INNER JOIN diam-diam membuang pelanggan yang belum pernah memesan — dan pertanyaan bisnis justru sering jatuh pada kelompok itu: pelanggan mana yang perlu dihubungi ulang, akun mana yang menganggur, data mana yang tidak punya pasangan? LEFT JOIN menjawabnya dengan membalik aturan: seluruh baris tabel kiri dipertahankan, dan baris kanan yang tidak menemukan pasangan diisi NULL. Chapter ini membedah isi NULL tersebut baris demi baris, memperkenalkan pola anti-join (kuda kerja query “cari yang tidak punya pasangan”), lalu membuka dua jebakan klasik: penghitungan count(*) versus count(kolom) dan posisi filter yang salah antara ON dan WHERE.
Semua Baris Kiri Dipertahankan #
LEFT JOIN mengevaluasi kondisi ON seperti INNER JOIN, lalu menambahkan satu aturan penyelamat: baris tabel kiri yang tidak menemukan satu pun pasangan tetap masuk hasil, dengan seluruh kolom tabel kanan diisi NULL. Perbandingannya ringkas:
| Situasi baris | INNER JOIN | LEFT JOIN (kiri = pelanggan) |
|---|---|---|
| Pasangan cocok ditemukan | tampil, kolom digabung | tampil, kolom digabung |
| Baris kiri tanpa pasangan | dibuang | tampil, kolom kanan NULL |
| Baris kanan tanpa pasangan | dibuang | dibuang |
Skema contoh masih domain yang sama; perhatikan kembali Andi Wijaya yang tidak punya pesanan:
-- Skema contoh: pelanggan 4 baris, pesanan 5 baris
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
);
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);
SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
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)
id | pelanggan_id | tanggal | total
-----+--------------+------------+-----------
101 | 1 | 2026-01-05 | 150000.00
102 | 1 | 2026-01-18 | 75000.00
103 | 2 | 2026-01-09 | 220000.00
104 | 4 | 2026-02-02 | 50000.00
105 | | 2026-02-10 | 30000.00
(5 rows)NULL sebagai Penambal Sisi Kanan #
Karena pelanggan ditulis di sebelah kiri kata LEFT JOIN, semua barisnya berhak tampil. Mari kita lihat NULL bekerja sebagai penambal:
-- LEFT JOIN: seluruh baris pelanggan tetap tampil, pesanan menempel bila ada
SELECT p.id, p.nama, ps.id AS pesanan, ps.tanggal, ps.total
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.id, ps.id;Output yang Dihasilkan:
id | nama | pesanan | tanggal | total
----+--------------+---------+------------+-----------
1 | Budi Santoso | 101 | 2026-01-05 | 150000.00
1 | Budi Santoso | 102 | 2026-01-18 | 75000.00
2 | Siti Rahayu | 103 | 2026-01-09 | 220000.00
3 | Andi Wijaya | | |
4 | Dewi Lestari | 104 | 2026-02-02 | 50000.00
(5 rows)Hasilnya lima baris — satu lebih banyak dari INNER JOIN. Bedah perannya:
FROM pelanggan p LEFT JOIN pesanan ps—pelangganadalah tabel kiri (ditulis pertama),pesanantabel kanan. Kata LEFT merujuk tabel pertama itu.- Baris 101–104 — pasangan cocok ditemukan, kolom dari kedua tabel tergabung normal, persis seperti INNER JOIN.
- Baris
3 | Andi Wijaya | (kosong) | (kosong) | (kosong)— Andi tidak punya pesanan, jadips.id,ps.tanggal, danps.totaldiisi NULL. Tiga kolom itu tampil kosong di output psql; kosong di sini berarti NULL, bukan string kosong. - Pesanan 105 tidak muncul — tabel kanan tetap tunduk pada aturan lama: baris kanan tanpa pasangan dibuang. Mengubah arah penulisan tabel adalah cara mengubah sisi yang dipertahankan, dan itu dibahas di chapter RIGHT JOIN .
NULL penambal ini bukan NULL yang tersimpan di tabel — NULL tersebut lahir dari proses join itu sendiri. Pembedaan ini penting saat membaca hasil: kolom pesanan yang kosong pada baris Andi tidak bermakna “ada pesanan dengan id kosong”, melainkan “tidak ada pesanan sama sekali”.
Pola Anti-Join: Mencari Baris Tanpa Pasangan #
Bentuk pemakaian LEFT JOIN paling produktif justru memanfaatkan baris NULL itu: gabungkan dengan LEFT JOIN, lalu saring baris yang kolom kanannya NULL. Pola ini disebut anti-join — kebalikan dari INNER JOIN:
-- Anti-join: pelanggan yang tidak punya satu pun pesanan
SELECT p.id, p.nama
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
WHERE ps.id IS NULL
ORDER BY p.id;Output yang Dihasilkan:
id | nama
----+-------------
3 | Andi Wijaya
(1 row)Mekanismenya bekerja dalam dua tahap:
LEFT JOIN ... ON ps.pelanggan_id = p.id— setiap pelanggan tampil; yang punya pesanan membawa pasangannya, yang tidak membawa NULL di seluruh kolomps.*.WHERE ps.id IS NULL— dari hasil itu, hanya baris yangps.id-nya NULL yang bertahan. Karenaps.idadalah primary key, NULL di kolom tersebut hanya bisa lahir dari penambalan join — artinya baris pelanggan itu benar-benar tanpa pesanan.
Pilihan kolom uji juga sengaja: ps.id, bukan ps.total. Kolom biasa seperti total sah bernilai NULL sebagai data, sehingga NULL di kolom itu tidak membuktikan absennya pasangan. Kolom yang dijamin terisi — seperti primary key — menjadi penanda yang bersih: satu-satunya sumber NULL di ps.id adalah penambalan join. Perbandingan = NULL tidak bisa dipakai di sini karena NULL tidak sama dengan NULL; hanya IS NULL yang menguji kekosongan (perilaku ini dibahas tuntas di chapter Operator Perbandingan
dan IS NULL
).
Pola anti-join muncul di mana-mana: produk yang belum pernah terjual, tagihan yang belum punya pembayaran, akun yang belum diaktivasi. Satu-satunya pesaingnya di PostgreSQL adalah NOT EXISTS dengan subquery — perbandingan keduanya dibahas di section Subqueries dan CTE
.
Menghitung dengan LEFT JOIN: count(*) versus count(kolom) #
LEFT JOIN sering berpasangan dengan agregasi: “berapa banyak pesanan tiap pelanggan?” dijawab dengan GROUP BY di atas hasil join. Di sinilah NULL menjalar ke penghitungan, karena dua bentuk count membaca NULL secara berbeda:
-- count(ps.id): hanya pesanan yang benar-benar ada yang dihitung
SELECT p.id, p.nama, count(ps.id) AS jumlah_pesanan
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;
-- count(*): seluruh baris hasil join yang dihitung, termasuk baris berisi NULL
SELECT p.id, p.nama, count(*) AS jumlah_baris
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;Output yang Dihasilkan:
id | nama | jumlah_pesanan
----+--------------+----------------
1 | Budi Santoso | 2
2 | Siti Rahayu | 1
3 | Andi Wijaya | 0
4 | Dewi Lestari | 1
(4 rows)
id | nama | jumlah_baris
----+--------------+--------------
1 | Budi Santoso | 2
2 | Siti Rahayu | 1
3 | Andi Wijaya | 1
4 | Dewi Lestari | 1
(4 rows)Kedua query mengembalikan empat baris yang sama, tetapi angkanya berbeda pada Andi Wijaya:
| Ekspresi | Yang dihitung | Andi Wijaya (0 pesanan) |
|---|---|---|
count(ps.id) | hanya nilai ps.id yang tidak NULL | 0 |
count(*) | seluruh baris hasil join, termasuk baris tambalan NULL | 1 |
count(kolom) mengabaikan NULL — dan satu-satunya baris Andi memang berisi NULL di ps.id, jadi hitungannya nol. count(*) menghitung baris apa adanya, sehingga baris tambalan pun dihitung satu. Untuk laporan “jumlah pesanan”, count(ps.id) adalah jawaban yang benar; count(*) menjawab pertanyaan berbeda (“jumlah baris hasil join”) yang kebetulan sama nilainya pada pelanggan yang punya pesanan. Perilaku dasar kedua bentuk ini dibahas di chapter COUNT
.
Jebakan Posisi Filter: ON atau WHERE #
Filter pada tabel kanan boleh ditaruh di dua tempat, dan keduanya menghasilkan jawaban yang berbeda. Taruh di ON, filter ikut menentukan pasangan mana yang cocok; taruh di WHERE, filter menyaring hasil setelah join selesai:
-- Filter di ON: pelanggan tanpa pesanan tetap tampil
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan p
LEFT JOIN pesanan ps
ON ps.pelanggan_id = p.id AND ps.total >= 100000.00
ORDER BY p.id, ps.id;
-- Filter di WHERE: pelanggan tanpa pesanan ikut terbuang
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan p
LEFT JOIN pesanan ps
ON ps.pelanggan_id = p.id
WHERE ps.total >= 100000.00
ORDER BY p.id, ps.id;Output yang Dihasilkan:
nama | pesanan | total
--------------+---------+-----------
Budi Santoso | 101 | 150000.00
Siti Rahayu | 103 | 220000.00
Andi Wijaya | |
Dewi Lestari | |
(4 rows)
nama | pesanan | total
--------------+---------+-----------
Budi Santoso | 101 | 150000.00
Siti Rahayu | 103 | 220000.00
(2 rows)Query pertama menghasilkan empat baris — semua pelanggan tampil; Budi membawa pesanan 101 (150000), Siti membawa 103 (220000), sementara Andi memang tanpa pesanan dan Dewi tampil kosong karena pesanannya (50000) tidak lolos ambang 100000. Query kedua hanya menyisakan dua baris. Penyebabnya: WHERE ps.total >= 100000.00 dievaluasi pada hasil join, dan NULL >= 100000.00 menghasilkan NULL — bukan true — sehingga baris tambalan (Andi yang tanpa pesanan, Dewi yang pesanannya tidak lolos) tersaring keluar. Efeknya, LEFT JOIN yang ditulis dengan tekun itu berubah perilaku menjadi INNER JOIN.
Aturan praktisnya: kondisi penggabungan dan filter tabel kanan ditulis di ON; kondisi bisnis pada tabel kiri atau pada hasil gabungan ditulis di WHERE. Pada INNER JOIN perbedaan ini tidak terasa — filter di ON maupun WHERE menghasilkan hasil yang sama — yang membuat jebakan ini mudah terlewat saat kode dipindahkan dari INNER ke LEFT JOIN.
LEFT JOIN mempertahankan sisi kiri; kelengkapan di sisi kanan justru jadi korban — pesanan 105 walk-in tidak akan pernah tampil di query manapun dari chapter ini. Membalik arah pertahanan itulah tugas RIGHT JOIN, sekaligus alasan mengapa banyak tim lebih suka menulis ulang RIGHT menjadi LEFT.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
anti-join.sql
Unduh berkas-- Anti-join: pelanggan yang tidak punya satu pun pesanan
SELECT p.id, p.nama
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
WHERE ps.id IS NULL
ORDER BY p.id;
buat-tabel.sql
Unduh berkas-- Skema contoh: pelanggan 4 baris, pesanan 5 baris
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
);
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);
SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;
filter-on-vs-where.sql
Unduh berkas-- Filter di ON: pelanggan tanpa pesanan tetap tampil
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan p
LEFT JOIN pesanan ps
ON ps.pelanggan_id = p.id AND ps.total >= 100000.00
ORDER BY p.id, ps.id;
-- Filter di WHERE: pelanggan tanpa pesanan ikut terbuang
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan p
LEFT JOIN pesanan ps
ON ps.pelanggan_id = p.id
WHERE ps.total >= 100000.00
ORDER BY p.id, ps.id;
hitung-pesanan.sql
Unduh berkas-- count(ps.id): hanya pesanan yang benar-benar ada yang dihitung
SELECT p.id, p.nama, count(ps.id) AS jumlah_pesanan
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;
-- count(*): seluruh baris hasil join yang dihitung, termasuk baris berisi NULL
SELECT p.id, p.nama, count(*) AS jumlah_baris
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;
left-dasar.sql
Unduh berkas-- LEFT JOIN: seluruh baris pelanggan tetap tampil, pesanan menempel bila ada
SELECT p.id, p.nama, ps.id AS pesanan, ps.tanggal, ps.total
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
ORDER BY p.id, ps.id;