“Produk mana yang punya riwayat penjualan?”, “pelanggan mana yang belum pernah memesan?”, “supplier mana yang belum punya satu pun barang terkirim?” — semua pertanyaan itu tidak membutuhkan isi baris pasangannya, cukup jawaban ya-tidak: ada atau tidak ada. SQL punya alat khusus yang dibangun persis untuk bentuk pertanyaan itu: EXISTS. Operator ini mengevaluasi subquery hanya sampai temuan pertama — satu baris yang cocok sudah cukup membuktikan keberadaan, sisanya tidak perlu diperiksa — dan mengabaikan isi kolom sama sekali. Pasangannya, NOT EXISTS, menjadi pola baku untuk kebalikannya: menemukan baris yang tidak punya pasangan. Babak terpenting chapter ini adalah duel kedua pola itu dengan NOT IN, karena NOT EXISTS kebal terhadap jebakan NULL yang telah kita lihat mengosongkan hasil query tanpa peringatan.

Tabel Latihan: produk, penjualan, dan pelanggan #

Chapter ini membutuhkan tiga tabel dari domain tetap: katalog 10 produk, 6 pelanggan, dan 10 penjualan Januari–Februari 2026. Dua fakta data menjadi bahan demo utama: empat produk belum pernah terjual, dua pelanggan (Andi Wijaya dan Yusuf Halim) belum pernah membeli, dan satu baris penjualan (id 8) tetap membawa produk_id NULL — pemain antagonis yang akan menggagalkan NOT IN di paruh akhir chapter:

sql buat-tabel.sql
-- Tabel latihan chapter EXISTS dan NOT EXISTS:
-- produk, penjualan, dan pelanggan Toko Maju Jaya.
CREATE TABLE produk (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kategori text NOT NULL,
    harga    numeric(10,2) NOT NULL,   -- harga satuan, rupiah
    stok     integer NOT NULL
);

CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    kota text NOT NULL
);

CREATE TABLE penjualan (
    id           integer PRIMARY KEY,
    tanggal      date NOT NULL,
    produk_id    integer REFERENCES produk (id),   -- NULL = barang tanpa kode produk
    pelanggan_id integer REFERENCES pelanggan (id),
    jumlah       integer NOT NULL                  -- unit terjual
);

INSERT INTO produk (id, nama, kategori, harga, stok) VALUES
    ( 1, 'Kopi Gayo',          'Minuman', 85000.00,  40),
    ( 2, 'Kopi Toraja',        'Minuman', 90000.00,  25),
    ( 3, 'Teh Melati',         'Minuman', 35000.00,  60),
    ( 4, 'Teh Hijau',          'Minuman', 40000.00,  50),
    ( 5, 'Gula Merah',         'Sembako', 15000.00, 100),
    ( 6, 'Beras Pandan Wangi', 'Sembako', 13000.00, 200),
    ( 7, 'Susu Kental Manis',  'Minuman', 18000.00,  30),
    ( 8, 'Keripik Singkong',   'Camilan', 20000.00,  45),
    ( 9, 'Kacang Goreng',      'Camilan', 25000.00,  55),
    (10, 'Madu Hutan',         'Sembako', 55000.00,  15);

INSERT INTO pelanggan (id, nama, kota) VALUES
    (1, 'Budi Santoso', 'Bandung'),
    (2, 'Siti Rahayu',  'Jakarta'),
    (3, 'Andi Wijaya',  'Surabaya'),
    (4, 'Dewi Lestari', 'Medan'),
    (5, 'Ratna Kusuma', 'Bandung'),
    (6, 'Yusuf Halim',  'Makassar');

INSERT INTO penjualan (id, tanggal, produk_id, pelanggan_id, jumlah) VALUES
    ( 1, DATE '2026-01-05', 1,    1, 10),
    ( 2, DATE '2026-01-08', 3,    2, 25),
    ( 3, DATE '2026-01-12', 5,    1, 40),
    ( 4, DATE '2026-01-15', 2,    1,  8),
    ( 5, DATE '2026-01-20', 1,    4, 12),
    ( 6, DATE '2026-01-22', 4,    2, 15),
    ( 7, DATE '2026-01-25', 5,    5, 30),
    ( 8, DATE '2026-02-02', NULL, 1, 20),
    ( 9, DATE '2026-02-05', 1,    4, 18),
    (10, DATE '2026-02-10', 6,    2, 22);

SELECT id, nama, kategori, harga FROM produk ORDER BY id;
SELECT id, nama, kota FROM pelanggan ORDER BY id;
SELECT id, tanggal, produk_id, pelanggan_id, jumlah FROM penjualan ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 10
INSERT 0 6
INSERT 0 10
 id |        nama        | kategori |  harga   
----+--------------------+----------+----------
  1 | Kopi Gayo          | Minuman  | 85000.00
  2 | Kopi Toraja        | Minuman  | 90000.00
  3 | Teh Melati         | Minuman  | 35000.00
  4 | Teh Hijau          | Minuman  | 40000.00
  5 | Gula Merah         | Sembako  | 15000.00
  6 | Beras Pandan Wangi | Sembako  | 13000.00
  7 | Susu Kental Manis  | Minuman  | 18000.00
  8 | Keripik Singkong   | Camilan  | 20000.00
  9 | Kacang Goreng      | Camilan  | 25000.00
 10 | Madu Hutan         | Sembako  | 55000.00
(10 rows)

 id |     nama     |   kota   
----+--------------+----------
  1 | Budi Santoso | Bandung
  2 | Siti Rahayu  | Jakarta
  3 | Andi Wijaya  | Surabaya
  4 | Dewi Lestari | Medan
  5 | Ratna Kusuma | Bandung
  6 | Yusuf Halim  | Makassar
(6 rows)

 id |  tanggal   | produk_id | pelanggan_id | jumlah 
----+------------+-----------+--------------+--------
  1 | 2026-01-05 |         1 |            1 |     10
  2 | 2026-01-08 |         3 |            2 |     25
  3 | 2026-01-12 |         5 |            1 |     40
  4 | 2026-01-15 |         2 |            1 |      8
  5 | 2026-01-20 |         1 |            4 |     12
  6 | 2026-01-22 |         4 |            2 |     15
  7 | 2026-01-25 |         5 |            5 |     30
  8 | 2026-02-02 |           |            1 |     20
  9 | 2026-02-05 |         1 |            4 |     18
 10 | 2026-02-10 |         6 |            2 |     22
(10 rows)

EXISTS: Cukup Ada Satu #

EXISTS (subquery) bernilai true bila subquery menghasilkan minimal satu baris dan false bila hasilnya kosong — tidak ada nilai tengah, tidak ada pemeriksaan isi. Karena itu pula penulisan konvensionalnya unik: daftar SELECT di dalam subquery boleh berisi apa pun, dan kebiasaan industri menulis SELECT 1 sebagai pernyataan bahwa “yang saya minta hanya keberadaan, bukan kolom”:

-- EXISTS (subquery): true bila subquery menghasilkan minimal satu baris,
-- false bila kosong. Isi kolomnya diabaikan -- yang dinilai hanya keberadaan.

-- Produk yang pernah terjual: cukup ADA satu penjualan dengan produk_id cocok.
SELECT p.id, p.nama
FROM produk p
WHERE EXISTS (SELECT 1
              FROM penjualan pn
              WHERE pn.produk_id = p.id)
ORDER BY p.id;

-- Konvensi SELECT 1 vs SELECT *: hasilnya identik, karena daftar SELECT
-- di dalam EXISTS tidak pernah dievaluasi sebagai nilai.
SELECT count(*) AS dengan_select_1
FROM produk p
WHERE EXISTS (SELECT 1
              FROM penjualan pn
              WHERE pn.produk_id = p.id);

SELECT count(*) AS dengan_select_bintang
FROM produk p
WHERE EXISTS (SELECT *
              FROM penjualan pn
              WHERE pn.produk_id = p.id);

Output yang Dihasilkan:

text
 id |        nama        
----+--------------------
  1 | Kopi Gayo
  2 | Kopi Toraja
  3 | Teh Melati
  4 | Teh Hijau
  5 | Gula Merah
  6 | Beras Pandan Wangi
(6 rows)

 dengan_select_1 
-----------------
               6
(1 row)

 dengan_select_bintang 
-----------------------
                     6
(1 row)

Query pertama menghasilkan enam produk yang pernah terjual — id 1 sampai 6. Mari kita bedah mekanismenya:

  1. WHERE EXISTS (SELECT 1 FROM penjualan pn WHERE pn.produk_id = p.id) — subquery berkorelasi dari chapter sebelumnya dipakai sebagai kondisi keberadaan: untuk tiap produk, PostgreSQL mencari satu baris penjualan dengan produk_id yang cocok.
  2. Efisiensi berhenti di temuan pertama — begitu satu baris penjualan cocok ditemukan, jawaban EXISTS sudah true dan sisa baris penjualan produk itu tidak perlu diperiksa. Kopi Gayo punya tiga penjualan, tetapi ketiganya tidak dibaca: temuan pertama menutup kasusnya.
  3. SELECT 1 di dalam subquery — kedua query penghitung membuktikan konvensi ini murni gaya penulisan: SELECT 1 dan SELECT * menghasilkan hitungan yang sama persis (enam), karena daftar SELECT subquery EXISTS tidak pernah dievaluasi menjadi nilai. SELECT 1 dipilih karena menyatakan maksud secara jujur — dan menegaskan bahwa mengganti SELECT * tidak mengubah apa pun.

Perhatikan pula apa yang TIDAK dilakukan EXISTS: hasil luar tidak berduplikasi walau Kopi Gayo terjual tiga kali — tiap produk tampil tepat satu baris — dan tidak ada satu pun kolom dari penjualan yang muncul di hasil. Perilaku ini diberi nama resmi semi-join: “setengah” join yang hanya mempertahankan baris luar yang berpasangan, tanpa menempelkan kolom dalam dan tanpa menduplikasi baris luar.

AspekINNER JOINEXISTS (semi-join)
Baris luar dengan beberapa pasanganterduplikasitampil sekali saja
Kolom tabel dalam di hasilbisa ditampilkantidak bisa
Baris luar tanpa pasangandibuangdibuang
Cocok untuk pertanyaan“gabungkan datanya”“siapa yang punya pasangan?”

Filter Berkorelasi di Dalam EXISTS #

Kekuatan EXISTS baru terasa ketika kondisi di dalam subquery diperketat. Semua kondisi — berkorelasi maupun biasa — bergabung di WHERE subquery, dan keberadaan diuji terhadap kondisi gabungan itu:

-- Kondisi berkorelasi dan kondisi biasa boleh digabung
-- di dalam subquery EXISTS.

-- Produk yang pernah terjual SEKALIGUS pernah terjual dalam jumlah besar
-- (minimal 20 unit dalam satu transaksi).
SELECT p.id, p.nama
FROM produk p
WHERE EXISTS (SELECT 1
              FROM penjualan pn
              WHERE pn.produk_id = p.id
                AND pn.jumlah >= 20)
ORDER BY p.id;

-- EXISTS juga cocok untuk keberadaan lintas tanggal:
-- produk yang terjual di Januari DAN Februari.
SELECT p.id, p.nama
FROM produk p
WHERE EXISTS (SELECT 1 FROM penjualan pn
              WHERE pn.produk_id = p.id
                AND pn.tanggal < DATE '2026-02-01')
  AND EXISTS (SELECT 1 FROM penjualan pn
              WHERE pn.produk_id = p.id
                AND pn.tanggal >= DATE '2026-02-01')
ORDER BY p.id;

Output yang Dihasilkan:

text
 id |        nama        
----+--------------------
  3 | Teh Melati
  5 | Gula Merah
  6 | Beras Pandan Wangi
(3 rows)

 id |   nama    
----+-----------
  1 | Kopi Gayo
(1 row)

Query pertama menjaring tiga produk yang pernah terjual sekurang-kurangnya 20 unit dalam satu transaksi: Teh Melati (25 unit), Gula Merah (40 lalu 30 unit), dan Beras Pandan Wangi (22 unit). Kopi Gayo — sang juara volume — justru gugur karena transaksinya berturut-turut hanya 10, 12, dan 18 unit. Query kedua memperlihatkan pola dua EXISTS berjajar: satu menguji keberadaan penjualan Januari, satu menguji Februari, dan hanya Kopi Gayo yang lolos keduanya. Pola ini sulit ditulis dengan IN tanpa berbelit, karena IN menguji keanggotaan satu daftar — sementara kebutuhannya adalah dua keanggotaan pada dua daftar yang berbeda sekaligus.

NOT EXISTS: Pola Anti-Join #

NOT EXISTS membalik jawabannya: baris luar lolos justru ketika subquery tidak menemukan satu pun pasangan. Inilah pola anti-join — kebalikan semi-join — dan bentuk paling alami untuk dua pertanyaan klasik operasional:

sql not-exists.sql
-- NOT EXISTS: baris luar yang TIDAK punya pasangan di subquery.
-- Inilah pola anti-join: kebalikan keberadaan.

-- Pelanggan yang belum pernah membeli sama sekali.
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
WHERE NOT EXISTS (SELECT 1
                  FROM penjualan pn
                  WHERE pn.pelanggan_id = pl.id)
ORDER BY pl.id;

-- Produk yang belum pernah terjual.
SELECT p.id, p.nama
FROM produk p
WHERE NOT EXISTS (SELECT 1
                  FROM penjualan pn
                  WHERE pn.produk_id = p.id)
ORDER BY p.id;

Output yang Dihasilkan:

text
 id |    nama     |   kota   
----+-------------+----------
  3 | Andi Wijaya | Surabaya
  6 | Yusuf Halim | Makassar
(2 rows)

 id |       nama        
----+-------------------
  7 | Susu Kental Manis
  8 | Keripik Singkong
  9 | Kacang Goreng
 10 | Madu Hutan
(4 rows)

Dua pertanyaan, dua jawaban: Andi Wijaya (Surabaya) dan Yusuf Halim (Makassar) adalah pelanggan tanpa satu pun transaksi; Susu Kental Manis, Keripik Singkong, Kacang Goreng, dan Madu Hutan adalah produk tanpa satu pun penjualan. Daftar seperti ini biasanya menjadi bahan tindak lanjut — kampanye untuk pelanggan pasif, evaluasi untuk produk buntu — dan pola NOT EXISTS menyajikannya tanpa syarat pada data: tidak perlu DISTINCT, tidak perlu takut duplikasi, tidak perlu memikirkan NULL.

NOT EXISTS versus NOT IN #

Tibalah di duel yang dijanjikan pembuka chapter. Kedua operator tampak saling menggantikan untuk pertanyaan “produk yang belum pernah terjual” — dan dengan data bersih keduanya memang identik. Tapi data kita tidak bersih: baris penjualan id 8 membawa produk_id NULL, dan di situlah keduanya berpisah jalan:

-- Duel pola anti-join: NOT EXISTS vs NOT IN
-- dengan data yang sama (penjualan memuat satu produk_id NULL).

-- Kandidat jawaban: NOT EXISTS -- produk yang belum pernah terjual.
SELECT p.id, p.nama
FROM produk p
WHERE NOT EXISTS (SELECT 1
                  FROM penjualan pn
                  WHERE pn.produk_id = p.id)
ORDER BY p.id;

-- NOT IN tanpa penjaga: nol baris, karena daftar berisi NULL.
SELECT p.id, p.nama
FROM produk p
WHERE p.id NOT IN (SELECT produk_id FROM penjualan)
ORDER BY p.id;

-- NOT IN dengan penjaga IS NOT NULL: jawaban yang benar muncul kembali.
SELECT p.id, p.nama
FROM produk p
WHERE p.id NOT IN (SELECT produk_id
                   FROM penjualan
                   WHERE produk_id IS NOT NULL)
ORDER BY p.id;

Output yang Dihasilkan:

text
 id |       nama        
----+-------------------
  7 | Susu Kental Manis
  8 | Keripik Singkong
  9 | Kacang Goreng
 10 | Madu Hutan
(4 rows)

 id | nama 
----+------
(0 rows)

 id |       nama        
----+-------------------
  7 | Susu Kental Manis
  8 | Keripik Singkong
  9 | Kacang Goreng
 10 | Madu Hutan
(4 rows)

Tiga hasil di layar menceritakan segalanya. Query pertama (NOT EXISTS) mengembalikan empat produk yang benar. Query kedua (NOT IN telanjang) mengembalikan nol baris — pola yang sudah dibedah di chapter Subquery dalam WHERE : NULL di daftar membuat setiap perbandingan <> bernilai UNKNOWN sehingga tak ada baris yang lolos. Query ketiga membetulkan NOT IN dengan menyingkirkan NULL di dalam subquery, dan jawaban yang benar muncul lagi. Mengapa NOT EXISTS kebal? Karena EXISTS tidak membandingkan nilai — ia menguji apakah ada baris yang memenuhi kondisi pn.produk_id = p.id, dan baris ber-NULL tidak pernah memenuhinya (NULL tidak sama dengan apa pun). NULL justru diabaikan secara alami, bukan meracuni hasil.

Kondisi subqueryNOT EXISTSNOT IN
Hasil kosong (nol baris)semua baris luar lolossemua baris luar lolos
Hasil mengandung NULLtidak terpengaruh — NULL tak pernah memenuhi kondisi korelasinol baris
Perlu penjaga tambahantidakya: WHERE kolom IS NOT NULL di dalam subquery

EXISTS, IN, dan Kenyataan Planner #

Pembaca yang teliti akan bertanya: kalau EXISTS dan IN sama-sama menguji keberadaan pasangan, mana yang lebih cepat? Jawaban jujurnya mengejutkan pemula: pada kasus yang setara, biasanya tidak ada yang lebih cepat, karena planner PostgreSQL mengubah keduanya menjadi rencana eksekusi yang sama — semi-join dengan strategi hash atau index yang paling murah. Perbedaan yang terasa adalah semantik (NOT EXISTS kebal NULL, IN lebih ringkas untuk daftar literal) dan keterbacaan di konteks masing-masing. Membuktikan klaim seperti ini bukan pekerjaan tebak-tebakan: EXPLAIN menampilkan rencana eksekusi yang sebenarnya, dan keterampilan membacanya dibahas di section Query Optimization .

EXISTS menuntaskan perangkat subquery klasik: satu nilai, daftar nilai, tabel sementara, korelasi per baris, dan kini keberadaan. Tetapi ada satu keluhan yang menempel pada semuanya — kedalaman tanda kurung. Query bertingkat membuat pembaca menghitung kurung dan menjaga logika di kepalanya. SQL menjawabnya dengan memberi nama pada tiap tahapan perhitungan, dan alat itu bernama WITH. Chapter berikutnya membahasnya: Common Table Expressions .

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- Tabel latihan chapter EXISTS dan NOT EXISTS:
-- produk, penjualan, dan pelanggan Toko Maju Jaya.
CREATE TABLE produk (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kategori text NOT NULL,
    harga    numeric(10,2) NOT NULL,   -- harga satuan, rupiah
    stok     integer NOT NULL
);

CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    kota text NOT NULL
);

CREATE TABLE penjualan (
    id           integer PRIMARY KEY,
    tanggal      date NOT NULL,
    produk_id    integer REFERENCES produk (id),   -- NULL = barang tanpa kode produk
    pelanggan_id integer REFERENCES pelanggan (id),
    jumlah       integer NOT NULL                  -- unit terjual
);

INSERT INTO produk (id, nama, kategori, harga, stok) VALUES
    ( 1, 'Kopi Gayo',          'Minuman', 85000.00,  40),
    ( 2, 'Kopi Toraja',        'Minuman', 90000.00,  25),
    ( 3, 'Teh Melati',         'Minuman', 35000.00,  60),
    ( 4, 'Teh Hijau',          'Minuman', 40000.00,  50),
    ( 5, 'Gula Merah',         'Sembako', 15000.00, 100),
    ( 6, 'Beras Pandan Wangi', 'Sembako', 13000.00, 200),
    ( 7, 'Susu Kental Manis',  'Minuman', 18000.00,  30),
    ( 8, 'Keripik Singkong',   'Camilan', 20000.00,  45),
    ( 9, 'Kacang Goreng',      'Camilan', 25000.00,  55),
    (10, 'Madu Hutan',         'Sembako', 55000.00,  15);

INSERT INTO pelanggan (id, nama, kota) VALUES
    (1, 'Budi Santoso', 'Bandung'),
    (2, 'Siti Rahayu',  'Jakarta'),
    (3, 'Andi Wijaya',  'Surabaya'),
    (4, 'Dewi Lestari', 'Medan'),
    (5, 'Ratna Kusuma', 'Bandung'),
    (6, 'Yusuf Halim',  'Makassar');

INSERT INTO penjualan (id, tanggal, produk_id, pelanggan_id, jumlah) VALUES
    ( 1, DATE '2026-01-05', 1,    1, 10),
    ( 2, DATE '2026-01-08', 3,    2, 25),
    ( 3, DATE '2026-01-12', 5,    1, 40),
    ( 4, DATE '2026-01-15', 2,    1,  8),
    ( 5, DATE '2026-01-20', 1,    4, 12),
    ( 6, DATE '2026-01-22', 4,    2, 15),
    ( 7, DATE '2026-01-25', 5,    5, 30),
    ( 8, DATE '2026-02-02', NULL, 1, 20),
    ( 9, DATE '2026-02-05', 1,    4, 18),
    (10, DATE '2026-02-10', 6,    2, 22);

SELECT id, nama, kategori, harga FROM produk ORDER BY id;
SELECT id, nama, kota FROM pelanggan ORDER BY id;
SELECT id, tanggal, produk_id, pelanggan_id, jumlah FROM penjualan ORDER BY id;

exists-dasar.sql

Unduh berkas
sql
-- EXISTS (subquery): true bila subquery menghasilkan minimal satu baris,
-- false bila kosong. Isi kolomnya diabaikan -- yang dinilai hanya keberadaan.

-- Produk yang pernah terjual: cukup ADA satu penjualan dengan produk_id cocok.
SELECT p.id, p.nama
FROM produk p
WHERE EXISTS (SELECT 1
              FROM penjualan pn
              WHERE pn.produk_id = p.id)
ORDER BY p.id;

-- Konvensi SELECT 1 vs SELECT *: hasilnya identik, karena daftar SELECT
-- di dalam EXISTS tidak pernah dievaluasi sebagai nilai.
SELECT count(*) AS dengan_select_1
FROM produk p
WHERE EXISTS (SELECT 1
              FROM penjualan pn
              WHERE pn.produk_id = p.id);

SELECT count(*) AS dengan_select_bintang
FROM produk p
WHERE EXISTS (SELECT *
              FROM penjualan pn
              WHERE pn.produk_id = p.id);

exists-filter.sql

Unduh berkas
sql
-- Kondisi berkorelasi dan kondisi biasa boleh digabung
-- di dalam subquery EXISTS.

-- Produk yang pernah terjual SEKALIGUS pernah terjual dalam jumlah besar
-- (minimal 20 unit dalam satu transaksi).
SELECT p.id, p.nama
FROM produk p
WHERE EXISTS (SELECT 1
              FROM penjualan pn
              WHERE pn.produk_id = p.id
                AND pn.jumlah >= 20)
ORDER BY p.id;

-- EXISTS juga cocok untuk keberadaan lintas tanggal:
-- produk yang terjual di Januari DAN Februari.
SELECT p.id, p.nama
FROM produk p
WHERE EXISTS (SELECT 1 FROM penjualan pn
              WHERE pn.produk_id = p.id
                AND pn.tanggal < DATE '2026-02-01')
  AND EXISTS (SELECT 1 FROM penjualan pn
              WHERE pn.produk_id = p.id
                AND pn.tanggal >= DATE '2026-02-01')
ORDER BY p.id;

not-exists.sql

Unduh berkas
sql
-- NOT EXISTS: baris luar yang TIDAK punya pasangan di subquery.
-- Inilah pola anti-join: kebalikan keberadaan.

-- Pelanggan yang belum pernah membeli sama sekali.
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
WHERE NOT EXISTS (SELECT 1
                  FROM penjualan pn
                  WHERE pn.pelanggan_id = pl.id)
ORDER BY pl.id;

-- Produk yang belum pernah terjual.
SELECT p.id, p.nama
FROM produk p
WHERE NOT EXISTS (SELECT 1
                  FROM penjualan pn
                  WHERE pn.produk_id = p.id)
ORDER BY p.id;

not-in-vs-not-exists.sql

Unduh berkas
sql
-- Duel pola anti-join: NOT EXISTS vs NOT IN
-- dengan data yang sama (penjualan memuat satu produk_id NULL).

-- Kandidat jawaban: NOT EXISTS -- produk yang belum pernah terjual.
SELECT p.id, p.nama
FROM produk p
WHERE NOT EXISTS (SELECT 1
                  FROM penjualan pn
                  WHERE pn.produk_id = p.id)
ORDER BY p.id;

-- NOT IN tanpa penjaga: nol baris, karena daftar berisi NULL.
SELECT p.id, p.nama
FROM produk p
WHERE p.id NOT IN (SELECT produk_id FROM penjualan)
ORDER BY p.id;

-- NOT IN dengan penjaga IS NOT NULL: jawaban yang benar muncul kembali.
SELECT p.id, p.nama
FROM produk p
WHERE p.id NOT IN (SELECT produk_id
                   FROM penjualan
                   WHERE produk_id IS NOT NULL)
ORDER BY p.id;