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 barisINNER JOINLEFT JOIN (kiri = pelanggan)
Pasangan cocok ditemukantampil, kolom digabungtampil, kolom digabung
Baris kiri tanpa pasangandibuangtampil, kolom kanan NULL
Baris kanan tanpa pasangandibuangdibuang

Skema contoh masih domain yang sama; perhatikan kembali Andi Wijaya yang tidak punya pesanan:

sql buat-tabel.sql
-- 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:

text
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:

sql left-dasar.sql
-- 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:

text
 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:

  1. FROM pelanggan p LEFT JOIN pesanan ps — pelanggan adalah tabel kiri (ditulis pertama), pesanan tabel kanan. Kata LEFT merujuk tabel pertama itu.
  2. Baris 101–104 — pasangan cocok ditemukan, kolom dari kedua tabel tergabung normal, persis seperti INNER JOIN.
  3. Baris 3 | Andi Wijaya | (kosong) | (kosong) | (kosong) — Andi tidak punya pesanan, jadi ps.id, ps.tanggal, dan ps.total diisi NULL. Tiga kolom itu tampil kosong di output psql; kosong di sini berarti NULL, bukan string kosong.
  4. 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:

sql anti-join.sql
-- 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:

text
 id |    nama     
----+-------------
  3 | Andi Wijaya
(1 row)

Mekanismenya bekerja dalam dua tahap:

  1. LEFT JOIN ... ON ps.pelanggan_id = p.id — setiap pelanggan tampil; yang punya pesanan membawa pasangannya, yang tidak membawa NULL di seluruh kolom ps.*.
  2. WHERE ps.id IS NULL — dari hasil itu, hanya baris yang ps.id-nya NULL yang bertahan. Karena ps.id adalah 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:

text
 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:

EkspresiYang dihitungAndi Wijaya (0 pesanan)
count(ps.id)hanya nilai ps.id yang tidak NULL0
count(*)seluruh baris hasil join, termasuk baris tambalan NULL1

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:

text
     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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;