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).

AspekSubquery mandiriSubquery berkorelasi
Rujukan kolom query luar di dalam subquerytidak adaada (mis. pn.produk_id = p.id)
Kapan dievaluasisekali, sebelum query luarsecara konseptual sekali per baris luar
Contoh pertanyaan“produk di atas rata-rata toko”“produk di atas rata-rata kategorinya”
Padanan tanpa subquerykonstanta / derived tableJOIN + 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:

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

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

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

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

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

KriteriaSubquery berkorelasiLEFT JOIN + GROUP BY
Strukturagregat di dalam subquery, per baris luaragregat di query utama, per kelompok
Baris tanpa pasangantetap tampil (hasil NULL/nol)tetap tampil (baris tambalan NULL)
Kolom tambahan dari tabel dalamsatu nilai per baris sajasemua kolom bebas dipilih
Keterbacaan untuk hitungan tunggalsering lebih jelasperlu 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;