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 subqueryHasil yang disumbatkanPeran di query luar
Daftar SELECTtepat satu baris satu kolom (satu nilai)ekspresi seperti angka biasa
Klausa WHEREsatu nilai, atau daftar nilai untuk IN / ANY / ALLpatokan penyaring baris
Klausa FROMtabel 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 :

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

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)

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:

sql tiga-posisi.sql
-- 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:

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

  1. 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.
  2. Posisi 2 (WHERE) — patokan yang sama kini menjadi syarat lolos: hanya Kopi Toraja, Kopi Gayo, dan Madu Hutan yang harganya melebihi 39600.00. Bandingkan dengan bentuk manual WHERE 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.
  3. Posisi 3 (FROM) — subquery dengan GROUP BY menghasilkan tabel tiga baris (satu per kategori), lalu tabel sementara bernama ringkas itu diperlakukan seperti tabel biasa: kolomnya dirujuk dengan ringkas.kategori, ringkas.rerata, dan bisa diurutkan dengan ORDER 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:

sql bersarang.sql
-- 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:

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

text
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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;