Pertanyaan bisnis nyata hampir selalu bertingkat: “produk mana yang harganya di atas rata-rata?”, “pelanggan mana yang belum pernah memesan?”, “kategori mana yang omzetnya di bawah target?”. Setiap pertanyaan itu membutuhkan dua perhitungan sekaligus — menghitung patokan (rata-rata, daftar pelanggan yang aktif, target per kategori), lalu membandingkan baris demi baris terhadap patokan itu. Menjawabnya dengan satu query datar terasa mustahil karena patokannya sendiri adalah hasil query. Subquery adalah jawaban SQL untuk masalah ini: sebuah perintah SELECT lengkap yang ditanam di dalam perintah SELECT lain, sehingga hasil query bagian dalam menjadi bahan baku bagi query bagian luar.
Apa itu Subquery #
Subquery (disebut juga inner query atau query dalam) adalah query yang berada di dalam query lain — query pembungkusnya disebut outer query. Eksekusinya dibayangkan berurutan: PostgreSQL menyelesaikan subquery terlebih dahulu, hasilnya berupa nilai atau kumpulan baris, lalu nilai itu dipakai oleh outer query seolah-olah sudah tertulis mentah di posisi subquery berada. Dua aturan bentuk berlaku tanpa kecuali: subquery selalu ditulis di dalam tanda kurung (...), dan subquery tidak boleh memuat ORDER BY sembarangan kecuali ada LIMIT — urutan hasil akhir tetap tanggung jawab outer query.
Bergantung posisinya, hasil subquery diperlakukan berbeda:
| Posisi subquery | Hasil yang disumbatkan | Peran di query luar |
|---|---|---|
Daftar SELECT | tepat satu baris satu kolom (satu nilai) | ekspresi seperti angka biasa |
Klausa WHERE | satu nilai, atau daftar nilai untuk IN / ANY / ALL | patokan penyaring baris |
Klausa FROM | tabel sementara (kumpulan baris dan kolom) | sumber baris seperti tabel sungguhan |
Ketiga posisi itu akan kita coba satu per satu dengan data yang sama, sehingga terlihat jelas bagaimana satu subquery bisa memainkan peran yang berbeda-beda.
Tabel Latihan: produk dan penjualan #
Section ini memakai satu domain tetap dari awal sampai akhir: katalog Toko Maju Jaya dengan 10 produk di tiga kategori, plus 10 transaksi penjualan sepanjang Januari–Februari 2026. Harga produk Kopi Gayo sampai Madu Hutan sengaja dibuat beragam agar rata-ratanya (39600.00 rupiah) memisahkan produk jadi dua kubu dengan jelas. Satu baris penjualan (id 8) memiliki produk_id kosong — barang curahan yang dijual tanpa kode produk — dan baris itu akan menjadi pemeran kejutan di chapter Subquery dalam WHERE
:
-- Tabel latihan untuk Section Subquery dan CTE:
-- katalog produk dan transaksi 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)Tiga Posisi Subquery dalam Satu File #
File berikut memakai patokan yang sama — rata-rata harga seluruh produk — dalam tiga peran berbeda. Perhatikan bahwa isi subquerynya identik (SELECT avg(harga) FROM produk) di dua query pertama; yang berubah hanya posisi dan akibatnya:
-- Subquery boleh duduk di tiga posisi: daftar SELECT, klausa WHERE,
-- dan klausa FROM. Ketiganya memakai tema yang sama: rata-rata harga
-- seluruh produk (39600.00 rupiah).
-- Posisi 1: SELECT list -- subquery sebagai ekspresi skalar,
-- hasilnya dipakai seperti nilai biasa di sisi kanan baris hasil.
SELECT p.nama,
p.harga,
round(p.harga - (SELECT avg(harga) FROM produk), 2) AS selisih_dari_rerata
FROM produk p
ORDER BY p.harga DESC, p.nama;
-- Posisi 2: WHERE -- subquery sebagai pembanding,
-- dijalankan dulu untuk menghasilkan satu angka, lalu angka itu
-- menjadi patokan filter bagi setiap baris produk.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga > (SELECT avg(harga) FROM produk)
ORDER BY p.harga DESC;
-- Posisi 3: FROM -- subquery sebagai tabel sementara (derived table);
-- hasilnya harus diberi alias (ringkas) supaya bisa dirujuk.
SELECT ringkas.kategori, ringkas.jumlah_produk, ringkas.rerata
FROM (SELECT kategori,
count(*) AS jumlah_produk,
round(avg(harga), 2) AS rerata
FROM produk
GROUP BY kategori) AS ringkas
ORDER BY ringkas.kategori;Output yang Dihasilkan:
nama | harga | selisih_dari_rerata
--------------------+----------+---------------------
Kopi Toraja | 90000.00 | 50400.00
Kopi Gayo | 85000.00 | 45400.00
Madu Hutan | 55000.00 | 15400.00
Teh Hijau | 40000.00 | 400.00
Teh Melati | 35000.00 | -4600.00
Kacang Goreng | 25000.00 | -14600.00
Keripik Singkong | 20000.00 | -19600.00
Susu Kental Manis | 18000.00 | -21600.00
Gula Merah | 15000.00 | -24600.00
Beras Pandan Wangi | 13000.00 | -26600.00
(10 rows)
nama | harga
-------------+----------
Kopi Toraja | 90000.00
Kopi Gayo | 85000.00
Madu Hutan | 55000.00
Teh Hijau | 40000.00
(4 rows)
kategori | jumlah_produk | rerata
----------+---------------+----------
Camilan | 2 | 22500.00
Minuman | 5 | 53600.00
Sembako | 3 | 27666.67
(3 rows)Mari kita bedah hasil ketiganya baris per baris:
- Posisi 1 (daftar
SELECT) — subquery menghasilkan satu nilai 39600.00, lalu nilai itu dikurangkan dari harga tiap produk.Kopi Toraja(90000.00) unggul 50400.00 dari rata-rata;Beras Pandan Wangi(13000.00) tertinggal 26600.00. Subquery di posisi ini bersifat seperti konstanta yang dihitung sekali. - Posisi 2 (
WHERE) — patokan yang sama kini menjadi syarat lolos: hanyaKopi Toraja,Kopi Gayo, danMadu Hutanyang harganya melebihi 39600.00. Bandingkan dengan bentuk manualWHERE harga > 39600.00: hasilnya sama, tetapi versi subquery selalu benar meski data produk bertambah atau berubah, karena rata-ratanya dihitung ulang setiap kali query berjalan. - Posisi 3 (
FROM) — subquery denganGROUP BYmenghasilkan tabel tiga baris (satu per kategori), lalu tabel sementara bernamaringkasitu diperlakukan seperti tabel biasa: kolomnya dirujuk denganringkas.kategori,ringkas.rerata, dan bisa diurutkan denganORDER BY.
Subquery di Dalam Subquery #
Subquery juga boleh bersarang — subquery di dalam subquery. Contoh klasiknya: “produk yang harganya di atas rata-rata kategori Teh Melati”. Rata-rata kategori Teh Melati tidak diketahui, dan kategori produk Teh Melati pun harus dicari dulu — dua langkah perhitungan, dua subquery bersarang:
-- Subquery boleh bersarang: subquery di dalam subquery.
-- Inner query mencari kategori produk bernama 'Teh Melati' ('Minuman');
-- middle query menghitung rata-rata harga kategori itu (53600.00);
-- outer query memakai angka itu sebagai patokan filter.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga > (SELECT avg(harga)
FROM produk
WHERE kategori = (SELECT kategori
FROM produk
WHERE nama = 'Teh Melati'))
ORDER BY p.harga DESC;
-- Subquery juga berlaku untuk daftar nilai di IN:
-- produk yang id-nya muncul di tabel penjualan.
SELECT p.id, p.nama
FROM produk p
WHERE p.id IN (SELECT produk_id FROM penjualan)
ORDER BY p.id;Output yang Dihasilkan:
nama | harga
-------------+----------
Kopi Toraja | 90000.00
Kopi Gayo | 85000.00
Madu Hutan | 55000.00
(3 rows)
id | nama
----+--------------------
1 | Kopi Gayo
2 | Kopi Toraja
3 | Teh Melati
4 | Teh Hijau
5 | Gula Merah
6 | Beras Pandan Wangi
(6 rows)Alur evaluasinya dari dalam ke luar: subquery terdalam mengembalikan Minuman (kategori produk Teh Melati), subquery tingkat dua menghitung rata-rata harga kategori Minuman yaitu 53600.00, lalu outer query memfilter semua produk dengan harga di atas angka itu — hasilnya Kopi Toraja, Kopi Gayo, dan Madu Hutan. Perhatikan bahwa Madu Hutan ikut lolos walau berasal dari kategori Sembako: subquery hanya menghasilkan satu angka patokan, dan outer query membandingkan semua produk terhadap angka itu tanpa peduli kategori asalnya. Query kedua memperlihatkan wujud subquery yang paling sering dipakai sehari-hari: IN (subquery) — subquery menghasilkan daftar nilai (enam produk_id yang pernah terjual), dan outer query menyaring produk yang id-nya ada di daftar tersebut.
Tanda Kurung Bukan Hiasan #
Aturan paling dasar subquery — selalu di dalam tanda kurung — mudah dilanggar tanpa sengaja saat mengetik cepat. Parser PostgreSQL tidak menebak maksud Anda; kata SELECT yang muncul tanpa pembuka tanda kurung langsung ditolak sebagai syntax error:
-- Subquery SELALU di dalam tanda kurung. Tanpa kurung, parser
-- PostgreSQL berhenti di kata SELECT dan melaporkan syntax error.
SELECT nama, harga
FROM produk
WHERE harga > SELECT avg(harga) FROM produk;Output yang Dihasilkan:
psql:tanpa-kurung.sql:5: ERROR: syntax error at or near "SELECT"
LINE 3: WHERE harga > SELECT avg(harga) FROM produk;
^Pesan syntax error at or near "SELECT" menunjuk tepat ke lokasi kata yang menggangu parser. Perbaikannya membungkus subquery dengan tanda kurung: WHERE harga > (SELECT avg(harga) FROM produk) — query yang sudah kita jalankan berkali-kali di atas.
Tiga posisi, satu pola dasar: hitung dulu, pakai kemudian. Posisi pertama — subquery sebagai satu nilai dalam ekspresi — punya aturan ketat tersendiri tentang berapa baris dan berapa kolom yang boleh dihasilkannya, dan melanggarnya memicu error yang akan sering Anda temui. Chapter berikutnya membedah posisi itu secara menyeluruh: Scalar Subquery .
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
bersarang.sql
Unduh berkas-- Subquery boleh bersarang: subquery di dalam subquery.
-- Inner query mencari kategori produk bernama 'Teh Melati' ('Minuman');
-- middle query menghitung rata-rata harga kategori itu (53600.00);
-- outer query memakai angka itu sebagai patokan filter.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga > (SELECT avg(harga)
FROM produk
WHERE kategori = (SELECT kategori
FROM produk
WHERE nama = 'Teh Melati'))
ORDER BY p.harga DESC;
-- Subquery juga berlaku untuk daftar nilai di IN:
-- produk yang id-nya muncul di tabel penjualan.
SELECT p.id, p.nama
FROM produk p
WHERE p.id IN (SELECT produk_id FROM penjualan)
ORDER BY p.id;
buat-tabel.sql
Unduh berkas-- Tabel latihan untuk Section Subquery dan CTE:
-- katalog produk dan transaksi 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;
tanpa-kurung.sql
Unduh berkas-- Subquery SELALU di dalam tanda kurung. Tanpa kurung, parser
-- PostgreSQL berhenti di kata SELECT dan melaporkan syntax error.
SELECT nama, harga
FROM produk
WHERE harga > SELECT avg(harga) FROM produk;
tiga-posisi.sql
Unduh berkas-- Subquery boleh duduk di tiga posisi: daftar SELECT, klausa WHERE,
-- dan klausa FROM. Ketiganya memakai tema yang sama: rata-rata harga
-- seluruh produk (39600.00 rupiah).
-- Posisi 1: SELECT list -- subquery sebagai ekspresi skalar,
-- hasilnya dipakai seperti nilai biasa di sisi kanan baris hasil.
SELECT p.nama,
p.harga,
round(p.harga - (SELECT avg(harga) FROM produk), 2) AS selisih_dari_rerata
FROM produk p
ORDER BY p.harga DESC, p.nama;
-- Posisi 2: WHERE -- subquery sebagai pembanding,
-- dijalankan dulu untuk menghasilkan satu angka, lalu angka itu
-- menjadi patokan filter bagi setiap baris produk.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga > (SELECT avg(harga) FROM produk)
ORDER BY p.harga DESC;
-- Posisi 3: FROM -- subquery sebagai tabel sementara (derived table);
-- hasilnya harus diberi alias (ringkas) supaya bisa dirujuk.
SELECT ringkas.kategori, ringkas.jumlah_produk, ringkas.rerata
FROM (SELECT kategori,
count(*) AS jumlah_produk,
round(avg(harga), 2) AS rerata
FROM produk
GROUP BY kategori) AS ringkas
ORDER BY ringkas.kategori;