“Mana yang lebih baik, subquery atau join?” — pertanyaan ini menemani hampir setiap diskusi SQL, dari forum pemula sampai meja code review. Setelah delapan chapter memegang kedua alat itu secara bergantian, kita akhirnya bisa menjawabnya dengan jujur: pertanyaannya sendiri yang keliru. Subquery dan join bukan dua kandidat bersaing untuk satu pekerjaan; keduanya alat dengan semantik berbeda yang sesekali bisa saling menggantikan. Perbedaan yang nyata bukan mitos “join selalu lebih cepat”, melainkan hal-hal konkret: berapa baris yang dihasilkan, kolom apa yang bisa tampil, dan pertanyaan macam apa yang sedang dijawab. Chapter penutup section ini mengadu keduanya pada data yang sama — memperlihatkan kapan hasilnya identik, kapan join justru menggelembungkan hasil, dan kapan hanya salah satu yang masuk akal — lalu menutupnya dengan tabel keputusan yang bisa Anda pegang sehari-hari.
Tabel Latihan: produk, pelanggan, dan penjualan #
Domain tetap section ini hadir lengkap: 10 produk, 6 pelanggan, dan 10 penjualan Januari–Februari 2026. Satu fakta data menjadi bahan duel utama: empat dari enam pelanggan aktif membeli dengan jumlah transaksi berbeda — Budi Santoso empat kali, Siti Rahayu tiga kali, Dewi Lestari dua kali, Ratna Kusuma sekali — sementara Andi Wijaya dan Yusuf Halim tidak bertransaksi sama sekali. Sebaran inilah yang membuat perbedaan semantik subquery dan join terlihat telanjang:
-- Tabel latihan chapter Subquery vs Join:
-- produk, pelanggan, dan penjualan 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:
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)IN versus INNER JOIN: Bedanya Ada di Baris #
Pertanyaan uji pertama: “pelanggan mana yang pernah membeli?” Bentuk subquery mengujinya lewat keanggotaan IN — dibahas penuh di chapter Subquery dalam WHERE
. Bentuk join mengujinya dengan menempelkan penjualan ke pelanggan. Logikanya terasa sama, hasilnya tidak:
-- Pertanyaan yang sama dijawab dua alat:
-- "pelanggan mana yang pernah membeli?"
-- Versi IN: keanggotaan di daftar pelanggan_id pada penjualan.
-- Tiap pelanggan tampil maksimal satu kali.
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
WHERE pl.id IN (SELECT pn.pelanggan_id FROM penjualan pn)
ORDER BY pl.id;
-- Versi INNER JOIN: tiap penjualan menempelkan baris pelanggannya --
-- pelanggan dengan banyak transaksi tampil berkali-kali.
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id
ORDER BY pl.id, pn.id;
-- Versi join yang diperbaiki: DISTINCT meringkas baris kembar.
SELECT DISTINCT pl.id, pl.nama, pl.kota
FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id
ORDER BY pl.id;
-- Ringkasan jumlah baris ketiganya dalam satu baris pembanding.
SELECT (SELECT count(*) FROM pelanggan pl
WHERE pl.id IN (SELECT pelanggan_id FROM penjualan)) AS baris_in,
(SELECT count(*) FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id) AS baris_join,
(SELECT count(DISTINCT pl.id) FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id) AS pelanggan_join_distinct;Output yang Dihasilkan:
id | nama | kota
----+--------------+---------
1 | Budi Santoso | Bandung
2 | Siti Rahayu | Jakarta
4 | Dewi Lestari | Medan
5 | Ratna Kusuma | Bandung
(4 rows)
id | nama | kota
----+--------------+---------
1 | Budi Santoso | Bandung
1 | Budi Santoso | Bandung
1 | Budi Santoso | Bandung
1 | Budi Santoso | Bandung
2 | Siti Rahayu | Jakarta
2 | Siti Rahayu | Jakarta
2 | Siti Rahayu | Jakarta
4 | Dewi Lestari | Medan
4 | Dewi Lestari | Medan
5 | Ratna Kusuma | Bandung
(10 rows)
id | nama | kota
----+--------------+---------
1 | Budi Santoso | Bandung
2 | Siti Rahayu | Jakarta
4 | Dewi Lestari | Medan
5 | Ratna Kusuma | Bandung
(4 rows)
baris_in | baris_join | pelanggan_join_distinct
----------+------------+-------------------------
4 | 10 | 4
(1 row)Fakta di output menegaskan perbedaan yang sering luput: versi IN mengembalikan empat baris — tiap pelanggan sekali — sedangkan versi INNER JOIN mengembalikan sepuluh baris: Budi Santoso empat kali, Siti Rahayu tiga kali, Dewi Lestari dua kali, Ratna Kusuma sekali. Baris ringkasan terakhir mencatatnya jujur: baris_in 4, baris_join 10, pelanggan_join_distinct 4. Penyebabnya murni semantik: IN menguji keanggotaan dan lupa jumlah kejadian, join menggabungkan baris demi baris sehingga pelanggan dengan n transaksi muncul n kali. Menambahkan DISTINCT memangkas hasil join kembali menjadi empat baris yang identik dengan IN:
| Aspek | IN (subquery) | INNER JOIN + DISTINCT |
|---|---|---|
| Jumlah baris hasil | satu baris per pelanggan | satu baris per pelanggan |
| Perlu perbaikan tambahan | tidak | ya: DISTINCT |
| Kolom dari tabel anak tampil | tidak bisa | bisa (hilang saat DISTINCT) |
| Paling alami untuk | “siapa yang punya transaksi?” | “tampilkan transaksinya” |
Anti-Join: NOT EXISTS versus LEFT JOIN … IS NULL #
Duel kedua membalik pertanyaannya: “pelanggan mana yang belum pernah membeli?” Kandidat subquery-nya NOT EXISTS — pola baku dari chapter EXISTS dan NOT EXISTS
. Kandidat join-nya LEFT JOIN + IS NULL: pelanggan tanpa pasangan tetap dipertahankan join kiri dengan baris tambalan NULL, lalu disaring justru oleh NULL-nya:
-- Pertanyaan anti-join: "pelanggan mana yang belum pernah membeli?"
-- Versi NOT EXISTS: uji keberadaan pasangan, dibalik.
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;
-- Versi LEFT JOIN ... IS NULL: pelanggan tanpa pasangan diberi baris
-- tambalan NULL, lalu disaring justru oleh NULL-nya.
-- Kolom yang diuji (pn.id) adalah primary key -- pasti NOT NULL
-- pada baris pasangan sungguhan, jadi NULL hanya mungkin berarti
-- "tidak ada pasangan".
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
LEFT JOIN penjualan pn ON pn.pelanggan_id = pl.id
WHERE pn.id IS NULL
ORDER BY pl.id;Output yang Dihasilkan:
id | nama | kota
----+-------------+----------
3 | Andi Wijaya | Surabaya
6 | Yusuf Halim | Makassar
(2 rows)
id | nama | kota
----+-------------+----------
3 | Andi Wijaya | Surabaya
6 | Yusuf Halim | Makassar
(2 rows)Kedua bentuk menghasilkan dua baris yang sama — Andi Wijaya (Surabaya) dan Yusuf Halim (Makassar) — dan pada data seperti ini keduanya memang padanan yang sah. Kesejatian padanan itu bergantung pada satu detail yang ditandai komentar di dalam file: kolom yang diuji pn.id IS NULL haruslah kolom yang tidak mungkin NULL pada baris pasangan sungguhan. Primary key pn.id memenuhi syarat itu; menguji kolom yang bisa NULL sendiri membuat baris pasangan sungguhan ikut lolos saringan dan hasilnya berbohong. NOT EXISTS bebas dari syarat ini karena tidak menguji nilai kolom sama sekali — dan ingat dari chapter EXISTS bahwa NOT IN bukan kandidat ketiga yang aman: satu NULL di daftar subquery membuat seluruh hasilnya kosong.
Saat Join Tidak Bisa Ditawar #
Subquery punya satu batas fundamental: hanya menghasilkan nilai atau keberadaan, tidak pernah menyatukan kolom. Begitu laporan meminta kolom dari beberapa tabel dalam satu baris, tidak ada bentuk subquery yang bisa menggantikan join:
-- Laporan butuh kolom dari tiga tabel sekaligus:
-- tanggal dan jumlah dari penjualan, nama pelanggan, nama produk.
-- Hanya join yang menyatukan kolom-kolom itu menjadi satu hasil.
SELECT pn.id AS penjualan_id,
pn.tanggal,
pl.nama AS pelanggan,
p.nama AS produk,
pn.jumlah
FROM penjualan pn
INNER JOIN pelanggan pl ON pl.id = pn.pelanggan_id
LEFT JOIN produk p ON p.id = pn.produk_id
ORDER BY pn.id;
-- Penjualan id 8 (produk_id NULL) tetap dilaporkan:
-- LEFT JOIN mempertahankannya dengan kolom produk kosong.Output yang Dihasilkan:
penjualan_id | tanggal | pelanggan | produk | jumlah
--------------+------------+--------------+--------------------+--------
1 | 2026-01-05 | Budi Santoso | Kopi Gayo | 10
2 | 2026-01-08 | Siti Rahayu | Teh Melati | 25
3 | 2026-01-12 | Budi Santoso | Gula Merah | 40
4 | 2026-01-15 | Budi Santoso | Kopi Toraja | 8
5 | 2026-01-20 | Dewi Lestari | Kopi Gayo | 12
6 | 2026-01-22 | Siti Rahayu | Teh Hijau | 15
7 | 2026-01-25 | Ratna Kusuma | Gula Merah | 30
8 | 2026-02-02 | Budi Santoso | | 20
9 | 2026-02-05 | Dewi Lestari | Kopi Gayo | 18
10 | 2026-02-10 | Siti Rahayu | Beras Pandan Wangi | 22
(10 rows)Sepuluh baris laporan tampil dengan empat kolom dari tiga tabel: penjualan_id dan tanggal dari penjualan, pelanggan dari pelanggan, produk dari produk. Perhatikan dua keputusan join di dalamnya: INNER JOIN pelanggan karena setiap penjualan wajib punya pelanggan, dan LEFT JOIN produk supaya penjualan id 8 (barang tanpa kode produk) tetap dilaporkan dengan kolom produk kosong — disiplin memilih tipe join dari chapter LEFT JOIN
. Subquery skalar memang bisa menyelipkan satu nilai per baris, tetapi menyusun lima kolom dari tiga tabel dengan subquery berarti tiga query bersarang yang saling berkorelasi — lebih panjang, lebih lambat dibaca, dan tidak menambah apa pun.
Saat Subquery Paling Jujur #
Kebalikannya juga benar: ada pertanyaan yang bentuk subquery-nya membaca dirinya seperti kalimat pertanyaannya, sementara padanan join-nya terasa dipaksakan. Kelas pertama adalah pembanding agregat — “lebih mahal dari rata-rata”:
-- Pertanyaan pembanding agregat: wilayah alami subquery.
-- "Produk apa yang lebih mahal dari rata-rata seluruh katalog?"
-- Versi subquery skalar: rata-rata dihitung sekali menjadi satu angka
-- pembanding -- bacaannya persis seperti pertanyaannya.
SELECT id, nama, kategori, harga
FROM produk
WHERE harga > (SELECT round(avg(harga), 2) FROM produk)
ORDER BY harga DESC, id;
-- Pertanyaan yang sama lewat join: CROSS JOIN terhadap derived table
-- satu baris. Hasil identik, tapi "cross join" tidak menyatakan
-- maksud "bandingkan dengan satu angka acuan".
SELECT p.id, p.nama, p.kategori, p.harga
FROM produk p
CROSS JOIN (SELECT round(avg(harga), 2) AS rata_rata FROM produk) r
WHERE p.harga > r.rata_rata
ORDER BY p.harga DESC, p.id;
-- "Produk apa yang lebih mahal dari rata-rata kategorinya sendiri?"
-- Subquery berkorelasi membaca tiap baris produk -- pertanyaan per-baris
-- seperti ini tidak punya padanan join datar yang setara dan lugas.
SELECT p.id, p.nama, p.kategori, p.harga
FROM produk p
WHERE p.harga > (SELECT round(avg(p2.harga), 2)
FROM produk p2
WHERE p2.kategori = p.kategori)
ORDER BY p.kategori, p.harga DESC, p.id;Output yang Dihasilkan:
id | nama | kategori | harga
----+-------------+----------+----------
2 | Kopi Toraja | Minuman | 90000.00
1 | Kopi Gayo | Minuman | 85000.00
10 | Madu Hutan | Sembako | 55000.00
4 | Teh Hijau | Minuman | 40000.00
(4 rows)
id | nama | kategori | harga
----+-------------+----------+----------
2 | Kopi Toraja | Minuman | 90000.00
1 | Kopi Gayo | Minuman | 85000.00
10 | Madu Hutan | Sembako | 55000.00
4 | Teh Hijau | Minuman | 40000.00
(4 rows)
id | nama | kategori | harga
----+---------------+----------+----------
9 | Kacang Goreng | Camilan | 25000.00
2 | Kopi Toraja | Minuman | 90000.00
1 | Kopi Gayo | Minuman | 85000.00
10 | Madu Hutan | Sembako | 55000.00
(4 rows)Rata-rata katalog adalah 39600.00, dan empat produk lolos: Kopi Toraja (90000.00), Kopi Gayo (85000.00), Madu Hutan (55000.00), serta Teh Hijau (40000.00) yang lolos tipis. Dua query pertama membuktikan kesetaraan: subquery skalar dan CROSS JOIN terhadap derived table satu baris menghasilkan baris yang persis sama — hanya cara bacanya yang berbeda, dan bacaan subquery menyiratkan maksud dengan lebih jujur. Query ketiga mempertajam pertanyaannya ke rata-rata per kategori — Minuman 53600.00, Camilan 22500.00, Sembako 27666.67 — dan daftar jawabannya berubah: Teh Hijau gugur (40000.00 di bawah rata-rata Minuman), Kacang Goreng masuk (25000.00 di atas rata-rata Camilan). Pertanyaan per-baris semacam ini adalah wilayah subquery berkorelasi dari chapter Correlated Subquery
: memaksa join datar untuk pertanyaan ini berarti menggandakan tabel produk dua kali dan menjaga agregasinya tetap benar secara manual.
Tabel Keputusan dan Kenyataan Planner #
Kini semua bukti terkumpul, dan satu pemahaman terakhir menutup kasusnya: untuk pasangan bentuk yang secara semantik setara — IN versus join + DISTINCT, NOT EXISTS versus LEFT JOIN ... IS NULL, subquery skalar versus CROSS JOIN derived table — planner PostgreSQL umumnya menyusun rencana eksekusi yang setara pula. Mitos “join selalu lebih cepat dari subquery” tidak berlaku pada SQL yang maknanya sama; perbedaan performa yang nyata lahir dari perbedaan semantik (seperti inflasi baris join) atau dari data dan index — bukan dari gaya penulisan. Karena itu pilih bentuk berdasarkan pertanyaan yang dijawab, lalu ukur dugaan performa dengan EXPLAIN — keterampilan yang dibahas tuntas di section Query Optimization
:
| Bentuk pertanyaan | Alat yang paling jujur |
|---|---|
| “Satukan kolom dari beberapa tabel” | JOIN |
| “Bandingkan tiap baris dengan satu angka agregat” | subquery skalar |
| “Baris di atas rata-rata kelompoknya sendiri” | subquery berkorelasi |
| “Siapa yang punya pasangan?” | IN / EXISTS |
| “Siapa yang tidak punya pasangan?” | NOT EXISTS |
| “Ringkasan anak digabung ke induk tanpa inflasi” | subquery di FROM / CTE, lalu join |
| “Lalui hierarki berapa pun dalamnya” | recursive CTE |
Section ini menutup perjalanan panjang kita di dunia query: dari subquery pertama yang bersarang di dalam WHERE, sampai recursive CTE yang melintasi hierarki berapa pun dalamnya. Semua alat telah terkumpul di tangan Anda — dan pertanyaan berikutnya berpindah dari “bagaimana menulis query-nya” ke “bagaimana merancang tabelnya supaya query menjadi alami”. Itulah pekerjaan section berikutnya: Relational Database Design
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Tabel latihan chapter Subquery vs Join:
-- produk, pelanggan, dan penjualan 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-vs-left-join.sql
Unduh berkas-- Pertanyaan anti-join: "pelanggan mana yang belum pernah membeli?"
-- Versi NOT EXISTS: uji keberadaan pasangan, dibalik.
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;
-- Versi LEFT JOIN ... IS NULL: pelanggan tanpa pasangan diberi baris
-- tambalan NULL, lalu disaring justru oleh NULL-nya.
-- Kolom yang diuji (pn.id) adalah primary key -- pasti NOT NULL
-- pada baris pasangan sungguhan, jadi NULL hanya mungkin berarti
-- "tidak ada pasangan".
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
LEFT JOIN penjualan pn ON pn.pelanggan_id = pl.id
WHERE pn.id IS NULL
ORDER BY pl.id;
in-vs-inner-join.sql
Unduh berkas-- Pertanyaan yang sama dijawab dua alat:
-- "pelanggan mana yang pernah membeli?"
-- Versi IN: keanggotaan di daftar pelanggan_id pada penjualan.
-- Tiap pelanggan tampil maksimal satu kali.
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
WHERE pl.id IN (SELECT pn.pelanggan_id FROM penjualan pn)
ORDER BY pl.id;
-- Versi INNER JOIN: tiap penjualan menempelkan baris pelanggannya --
-- pelanggan dengan banyak transaksi tampil berkali-kali.
SELECT pl.id, pl.nama, pl.kota
FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id
ORDER BY pl.id, pn.id;
-- Versi join yang diperbaiki: DISTINCT meringkas baris kembar.
SELECT DISTINCT pl.id, pl.nama, pl.kota
FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id
ORDER BY pl.id;
-- Ringkasan jumlah baris ketiganya dalam satu baris pembanding.
SELECT (SELECT count(*) FROM pelanggan pl
WHERE pl.id IN (SELECT pelanggan_id FROM penjualan)) AS baris_in,
(SELECT count(*) FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id) AS baris_join,
(SELECT count(DISTINCT pl.id) FROM pelanggan pl
INNER JOIN penjualan pn ON pn.pelanggan_id = pl.id) AS pelanggan_join_distinct;
laporan-butuh-join.sql
Unduh berkas-- Laporan butuh kolom dari tiga tabel sekaligus:
-- tanggal dan jumlah dari penjualan, nama pelanggan, nama produk.
-- Hanya join yang menyatukan kolom-kolom itu menjadi satu hasil.
SELECT pn.id AS penjualan_id,
pn.tanggal,
pl.nama AS pelanggan,
p.nama AS produk,
pn.jumlah
FROM penjualan pn
INNER JOIN pelanggan pl ON pl.id = pn.pelanggan_id
LEFT JOIN produk p ON p.id = pn.produk_id
ORDER BY pn.id;
-- Penjualan id 8 (produk_id NULL) tetap dilaporkan:
-- LEFT JOIN mempertahankannya dengan kolom produk kosong.
saatnya-subquery.sql
Unduh berkas-- Pertanyaan pembanding agregat: wilayah alami subquery.
-- "Produk apa yang lebih mahal dari rata-rata seluruh katalog?"
-- Versi subquery skalar: rata-rata dihitung sekali menjadi satu angka
-- pembanding -- bacaannya persis seperti pertanyaannya.
SELECT id, nama, kategori, harga
FROM produk
WHERE harga > (SELECT round(avg(harga), 2) FROM produk)
ORDER BY harga DESC, id;
-- Pertanyaan yang sama lewat join: CROSS JOIN terhadap derived table
-- satu baris. Hasil identik, tapi "cross join" tidak menyatakan
-- maksud "bandingkan dengan satu angka acuan".
SELECT p.id, p.nama, p.kategori, p.harga
FROM produk p
CROSS JOIN (SELECT round(avg(harga), 2) AS rata_rata FROM produk) r
WHERE p.harga > r.rata_rata
ORDER BY p.harga DESC, p.id;
-- "Produk apa yang lebih mahal dari rata-rata kategorinya sendiri?"
-- Subquery berkorelasi membaca tiap baris produk -- pertanyaan per-baris
-- seperti ini tidak punya padanan join datar yang setara dan lugas.
SELECT p.id, p.nama, p.kategori, p.harga
FROM produk p
WHERE p.harga > (SELECT round(avg(p2.harga), 2)
FROM produk p2
WHERE p2.kategori = p.kategori)
ORDER BY p.kategori, p.harga DESC, p.id;