Semua subquery sejauh ini berdiri sendiri: subquery dijalankan sekali, hasilnya selesai, lalu query luar memanfaatkannya. Ada bentuk lain yang jauh lebih lentur — subquery yang membaca kolom milik query luar. PostgreSQL menyebutnya subquery berkorelasi (correlated subquery): isi subquery berubah mengikuti baris luar yang sedang diproses, sehingga satu pernyataan SQL mampu menghitung “berapa kali produk ini terjual” atau “apakah harga produk ini di atas rata-rata kategorinya” — pertanyaan yang patokannya berbeda untuk setiap baris. Fleksibilitas itu datang dengan tagihan: secara konseptual subquery dievaluasi berulang kali, sekali per baris luar. Chapter ini membangun intuisi eksekusinya, membandingkannya dengan padanan JOIN + GROUP BY, dan menutup dengan catatan performa yang wajib dipahami sebelum dipakai di tabel besar.
Apa itu Korelasi #
Subquery berkorelasi dikenali dari satu tanda sintaks: di dalam tanda kurungnya ada rujukan kolom yang berasal dari tabel query luar — misalnya p.id yang dimiliki FROM produk p di query luar. Rujukan semacam itu tidak valid pada subquery mandiri, dan justru kevalidannyalah yang menciptakan hubungan dua arah: query luar menyuplai nilai (p.id baris saat ini), subquery mengembalikan hasil yang tergantung pada nilai itu (banyaknya penjualan produk tersebut).
| Aspek | Subquery mandiri | Subquery berkorelasi |
|---|---|---|
| Rujukan kolom query luar di dalam subquery | tidak ada | ada (mis. pn.produk_id = p.id) |
| Kapan dievaluasi | sekali, sebelum query luar | secara konseptual sekali per baris luar |
| Contoh pertanyaan | “produk di atas rata-rata toko” | “produk di atas rata-rata kategorinya” |
| Padanan tanpa subquery | konstanta / derived table | JOIN + GROUP BY, atau LATERAL |
Dua contoh pertanyaan di tabel itu bukan pemanis: keduanya akan kita jalankan sungguhan dengan data yang sama, dan perbedaan hasilnya memperlihatkan arti korelasi secara langsung.
Tabel Latihan: produk dan penjualan #
Domain tetap hadir lagi: katalog 10 produk dan 10 transaksi Toko Maju Jaya. Kolom kategori pada produk akan menjadi motor korelasi di bagian WHERE — tiga kategori dengan rata-rata harga berbeda (53600.00 untuk Minuman, 27666.67 untuk Sembako, 22500.00 untuk Camilan) — dan tabel penjualan menjadi motor korelasi di daftar SELECT:
-- Tabel latihan chapter Correlated Subquery:
-- katalog produk 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 penjualan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
produk_id integer REFERENCES produk (id), -- NULL = barang tanpa kode produk
pelanggan_id integer NOT NULL,
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 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, stok FROM produk ORDER BY id;
SELECT id, tanggal, produk_id, pelanggan_id, jumlah FROM penjualan ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
INSERT 0 10
INSERT 0 10
id | nama | kategori | harga | stok
----+--------------------+----------+----------+------
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
(10 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)Korelasi di Daftar SELECT: Kolom yang Dihitung per Baris #
Pertanyaan pertama: untuk setiap produk, berapa kali produk itu terjual dan berapa total unitnya? Subquery di dalam daftar SELECT menjawabnya dengan merujuk p.id — baris demi baris:
-- Subquery berkorelasi di daftar SELECT:
-- subquery merujuk p.id milik query luar, sehingga hitungannya
-- mengikuti baris produk yang sedang diproses.
-- Berapa kali tiap produk terjual, dan berapa total unitnya?
-- Produk yang belum pernah terjual tetap tampil dengan angka 0.
SELECT p.nama,
(SELECT count(*)
FROM penjualan pn
WHERE pn.produk_id = p.id) AS transaksi,
coalesce((SELECT sum(pn.jumlah)
FROM penjualan pn
WHERE pn.produk_id = p.id), 0) AS total_unit
FROM produk p
ORDER BY total_unit DESC, p.nama;Output yang Dihasilkan:
nama | transaksi | total_unit
--------------------+-----------+------------
Gula Merah | 2 | 70
Kopi Gayo | 3 | 40
Teh Melati | 1 | 25
Beras Pandan Wangi | 1 | 22
Teh Hijau | 1 | 15
Kopi Toraja | 1 | 8
Kacang Goreng | 0 | 0
Keripik Singkong | 0 | 0
Madu Hutan | 0 | 0
Susu Kental Manis | 0 | 0
(10 rows)Model mental eksekusinya adalah perulangan: query luar berjalan baris demi baris, dan untuk setiap baris, subquery dievaluasi dengan nilai p.id baris itu.
Hasilnya sepuluh baris — seluruh katalog. Gula Merah unggul dengan 70 unit dari 2 transaksi, disusul Kopi Gayo dengan 40 unit dari 3 transaksi, dan empat produk yang belum pernah terjual (Kacang Goreng, Keripik Singkong, Madu Hutan, Susu Kental Manis) tetap tampil dengan angka nol. Dua detail penulisan penting di sini. Pertama, sum(pn.jumlah) atas produk tanpa penjualan menghasilkan NULL (agregat atas himpunan kosong), sehingga coalesce(..., 0) diperlukan untuk merapikannya menjadi nol. Kedua, subquery yang sama sekali berdiri sendiri tanpa pn.produk_id = p.id akan menghitung total seluruh penjualan untuk semua baris — korelasi itulah yang membedakan “per produk” dari “total toko”.
Korelasi di WHERE: Patokan yang Mengikuti Baris #
Pertanyaan kedua memperlihatkan korelasi di posisi penyaring: produk mana yang lebih mahal dari rata-rata kategorinya sendiri? Chapter Scalar Subquery
menghitung rata-rata toko sekali (39600.00); kini patokannya bergerak — Minuman memakai 53600.00, Sembako memakai 27666.67, Camilan memakai 22500.00:
-- Subquery berkorelasi di klausa WHERE:
-- patokan rata-rata dihitung ulang mengikuti kategori
-- dari baris produk yang sedang diuji.
-- Produk yang lebih mahal dari rata-rata kategorinya sendiri.
SELECT p.nama, p.kategori, p.harga,
(SELECT round(avg(p2.harga), 2)
FROM produk p2
WHERE p2.kategori = p.kategori) AS rerata_kategori
FROM produk p
WHERE p.harga > (SELECT avg(p2.harga)
FROM produk p2
WHERE p2.kategori = p.kategori)
ORDER BY p.kategori, p.nama;Output yang Dihasilkan:
nama | kategori | harga | rerata_kategori
---------------+----------+----------+-----------------
Kacang Goreng | Camilan | 25000.00 | 22500.00
Kopi Gayo | Minuman | 85000.00 | 53600.00
Kopi Toraja | Minuman | 90000.00 | 53600.00
Madu Hutan | Sembako | 55000.00 | 27666.67
(4 rows)Empat produk lolos: Kopi Gayo dan Kopi Toraja di atas rata-rata Minuman, Madu Hutan di atas rata-rata Sembako, Kacang Goreng di atas rata-rata Camilan. Bandingkan dengan hasil scalar subquery mandiri di chapter sebelumnya yang memakai satu patokan toko (39600.00) — Teh Hijau (40000.00) dan Teh Melati (35000.00) ikut lolos di sana, tetapi keduanya justru di bawah rata-rata Minuman sehingga gugur di sini. Kolom rerata_kategori sengaja ditampilkan di samping hasilnya agar patokan yang berbeda-beda per baris terlihat telanjang. Alias p2 untuk tabel produk kedua juga wajib — tanpa alias, kategori dan p.kategori akan bertabrakan nama karena query dan subquery membaca tabel yang sama (pola self-join dari chapter SELF JOIN
).
Padanan Tanpa Subquery: LEFT JOIN dan GROUP BY #
Korelasi di daftar SELECT punya padanan klasik: gabungkan tabelnya, lalu agregatkan. Query berikut menghasilkan angka yang sama persis dengan korelasi-select.sql tanpa satu pun subquery:
-- Padanan tanpa subquery berkorelasi:
-- LEFT JOIN + GROUP BY menghasilkan angka yang sama persis.
SELECT p.nama,
count(pn.id) AS transaksi,
coalesce(sum(pn.jumlah), 0) AS total_unit
FROM produk p
LEFT JOIN penjualan pn ON pn.produk_id = p.id
GROUP BY p.id, p.nama
ORDER BY total_unit DESC, p.nama;
-- Catatan kecil untuk verifikasi angka:
-- total unit seluruh katalog harus sama dengan total tabel penjualan
-- untuk produk tercatat (180 unit dari 9 transaksi ber-produk_id).
SELECT sum(jumlah) AS total_penjualan_tercatat
FROM penjualan
WHERE produk_id IS NOT NULL;Output yang Dihasilkan:
nama | transaksi | total_unit
--------------------+-----------+------------
Gula Merah | 2 | 70
Kopi Gayo | 3 | 40
Teh Melati | 1 | 25
Beras Pandan Wangi | 1 | 22
Teh Hijau | 1 | 15
Kopi Toraja | 1 | 8
Kacang Goreng | 0 | 0
Keripik Singkong | 0 | 0
Madu Hutan | 0 | 0
Susu Kental Manis | 0 | 0
(10 rows)
total_penjualan_tercatat
--------------------------
180
(1 row)Sepuluh baris hasil LEFT JOIN + GROUP BY identik dengan versi subquery berkorelasi — dan query verifikasi menegaskan konsistensinya: 180 unit. Dua keputusan teknis di query itu meniru perilaku subquery secara eksak: LEFT JOIN mempertahankan produk tanpa penjualan (peran yang dimainkan korelasi secara otomatis), dan count(pn.id) — bukan count(*) — menghasilkan nol bagi produk tak berpasangan karena count(kolom) melewati NULL, perilaku yang dibahas di chapter COUNT
.
| Kriteria | Subquery berkorelasi | LEFT JOIN + GROUP BY |
|---|---|---|
| Struktur | agregat di dalam subquery, per baris luar | agregat di query utama, per kelompok |
| Baris tanpa pasangan | tetap tampil (hasil NULL/nol) | tetap tampil (baris tambalan NULL) |
| Kolom tambahan dari tabel dalam | satu nilai per baris saja | semua kolom bebas dipilih |
| Keterbacaan untuk hitungan tunggal | sering lebih jelas | perlu membayangkan pembentukan kelompok |
Biaya Eksekusi: Model Mental dan Realita Planner #
Model mental “satu evaluasi per baris luar” adalah cara berpikir yang benar untuk memprediksi hasil — tetapi bukan janji tentang cara PostgreSQL benar-benar mengeksekusinya. Planner PostgreSQL berhak menulis ulang query: banyak pola subquery berkorelasi ditransformasi menjadi join atau hash aggregate sehingga tabel dalam dibaca sekali, bukan berulang. Sebaliknya, ada bentuk yang sulit dioptimalkan dan benar-benar dievaluasi per baris (pola nested loop), dan pada tabel besar pola itu terasa berat: 100 ribu baris luar kali pembacaan bertingkat menghasilkan jutaan pembandingan.
Karena itu dua kebiasaan berikut layak dibawa sejak sekarang. Pertama, untuk tabel kecil dan pertanyaan ad-hoc, pilih bentuk yang paling terbaca — korelasi sering menang kejelasan, dan performa tidak relevan pada puluhan ribu baris. Kedua, bila query berkorelasi berjalan di tabel besar dan terasa lambat, jangan menebak dari kursi — buka EXPLAIN, lihat rencana eksekusi yang sebenarnya, dan biarkan bukti yang menentukan langkah berikutnya. Membaca rencana eksekusi adalah keahlian tersendiri yang dibahas tuntas di section Query Optimization
.
Subquery berkorelasi menghitung nilai per baris luar; bentuk berikutnya menggunakan korelasi hanya untuk menjawab satu pertanyaan ya-tidak: “apakah ada baris yang cocok?” — dan berhenti di temuan pertama. Operator EXISTS dan pasangannya NOT EXISTS adalah pola semi-join dan anti-join yang menjadi standar industri untuk pertanyaan keberadaan. Chapter berikutnya membahasnya: EXISTS dan NOT EXISTS
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
alternatif-group-by.sql
Unduh berkas-- Padanan tanpa subquery berkorelasi:
-- LEFT JOIN + GROUP BY menghasilkan angka yang sama persis.
SELECT p.nama,
count(pn.id) AS transaksi,
coalesce(sum(pn.jumlah), 0) AS total_unit
FROM produk p
LEFT JOIN penjualan pn ON pn.produk_id = p.id
GROUP BY p.id, p.nama
ORDER BY total_unit DESC, p.nama;
-- Catatan kecil untuk verifikasi angka:
-- total unit seluruh katalog harus sama dengan total tabel penjualan
-- untuk produk tercatat (180 unit dari 9 transaksi ber-produk_id).
SELECT sum(jumlah) AS total_penjualan_tercatat
FROM penjualan
WHERE produk_id IS NOT NULL;
buat-tabel.sql
Unduh berkas-- Tabel latihan chapter Correlated Subquery:
-- katalog produk 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 penjualan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
produk_id integer REFERENCES produk (id), -- NULL = barang tanpa kode produk
pelanggan_id integer NOT NULL,
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 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, stok FROM produk ORDER BY id;
SELECT id, tanggal, produk_id, pelanggan_id, jumlah FROM penjualan ORDER BY id;
korelasi-select.sql
Unduh berkas-- Subquery berkorelasi di daftar SELECT:
-- subquery merujuk p.id milik query luar, sehingga hitungannya
-- mengikuti baris produk yang sedang diproses.
-- Berapa kali tiap produk terjual, dan berapa total unitnya?
-- Produk yang belum pernah terjual tetap tampil dengan angka 0.
SELECT p.nama,
(SELECT count(*)
FROM penjualan pn
WHERE pn.produk_id = p.id) AS transaksi,
coalesce((SELECT sum(pn.jumlah)
FROM penjualan pn
WHERE pn.produk_id = p.id), 0) AS total_unit
FROM produk p
ORDER BY total_unit DESC, p.nama;
korelasi-where.sql
Unduh berkas-- Subquery berkorelasi di klausa WHERE:
-- patokan rata-rata dihitung ulang mengikuti kategori
-- dari baris produk yang sedang diuji.
-- Produk yang lebih mahal dari rata-rata kategorinya sendiri.
SELECT p.nama, p.kategori, p.harga,
(SELECT round(avg(p2.harga), 2)
FROM produk p2
WHERE p2.kategori = p.kategori) AS rerata_kategori
FROM produk p
WHERE p.harga > (SELECT avg(p2.harga)
FROM produk p2
WHERE p2.kategori = p.kategori)
ORDER BY p.kategori, p.nama;