Bentuk subquery yang paling mudah dipahami sekaligus paling sering dipakai adalah yang menghasilkan satu nilai tunggal: satu baris, satu kolom. PostgreSQL menyebutnya scalar subquery — kata scalar berarti nilainya sejenis dengan angka atau teks biasa, bukan tabel. Karena hasilnya satu nilai, subquery jenis ini bisa ditempatkan di mana pun sebuah ekspresi biasa diperbolehkan: di daftar SELECT sebagai kolom hasil, di WHERE sebagai pembanding, bahkan di dalam CASE atau perhitungan aritmetika. Sayangnya kebebasan posisi itu datang dengan syarat yang ketat — dan pelanggaran syaratnya menghasilkan dua error paling ikonik di dunia subquery. Chapter ini membedah semuanya dengan katalog produk Toko Maju Jaya yang sama dengan chapter sebelumnya.
Syarat Bentuk: Satu Baris, Satu Kolom #
Scalar subquery harus menghasilkan tepat satu baris dan tepat satu kolom — bentuknya persis seperti sel tunggal di lembar spreadsheet. Ketatnya aturan ini bukan capricious: satu nilai hanya punya satu tempat di ekspresi, jadi hasil berbentuk lain tidak punya arti yang terdefinisi. Empat kemungkinan bentuk hasil, dan nasibnya masing-masing:
| Bentuk hasil subquery | Nasib saat dipakai sebagai ekspresi |
|---|---|
| 1 baris, 1 kolom | sah — nilai itu yang dipakai |
| 0 baris, 1 kolom | sah — nilainya NULL, bukan error |
| 2 baris atau lebih, 1 kolom | error saat runtime: more than one row returned by... |
| 1 baris, 2 kolom atau lebih | error saat parse: subquery must return only one column |
Dua baris terakhir adalah jebakan yang hanya terlihat saat query berjalan — dan yang pertama (baris berlebih) biasanya baru muncul di produksi, ketika data yang tadinya unik mulai berduplikat. Kita akan memicu keduanya secara sengaja di paruh akhir chapter.
Tabel Latihan: produk #
Chapter ini hanya membutuhkan satu tabel: katalog 10 produk Toko Maju Jaya di tiga kategori. Harga berkisar 13000.00 (Beras Pandan Wangi) sampai 90000.00 (Kopi Toraja) dengan rata-rata toko 39600.00 — angka-angka itu akan menjadi patokan berulang sepanjang contoh:
-- Tabel latihan chapter Scalar Subquery:
-- katalog produk Toko Maju Jaya (domain section Subquery dan CTE).
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
);
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);
SELECT id, nama, kategori, harga, stok FROM produk ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
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)Scalar Subquery di Daftar SELECT #
Posisi pertama: di daftar SELECT, berdampingan dengan kolom biasa. Subquery (SELECT max(harga) FROM produk) menghasilkan 90000.00 — satu angka yang kemudian diperlakukan seperti tertulis begitu saja di query:
-- Scalar subquery di daftar SELECT: hasilnya dipakai layaknya
-- konstanta yang dihitung sekali sebelum baris demi baris diproses.
-- Patokan: harga produk termahal (90000.00).
SELECT p.nama,
p.harga,
(SELECT max(harga) FROM produk) AS harga_termahal,
round(p.harga * 100.0 / (SELECT max(harga) FROM produk), 1) AS persen_dari_termahal
FROM produk p
ORDER BY p.harga DESC, p.nama;
-- Subquery yang sama bisa dipakai berulang kali dalam satu query:
-- sekali sebagai pembanding mutlak, sekali sebagai penyebut persentase.
SELECT p.nama,
p.harga,
(SELECT round(avg(harga), 2) FROM produk) AS rerata_toko,
CASE
WHEN p.harga > (SELECT avg(harga) FROM produk) THEN 'di atas rerata'
ELSE 'di bawah rerata'
END AS posisi
FROM produk p
WHERE p.kategori = 'Sembako'
ORDER BY p.harga DESC;Output yang Dihasilkan:
nama | harga | harga_termahal | persen_dari_termahal
--------------------+----------+----------------+----------------------
Kopi Toraja | 90000.00 | 90000.00 | 100.0
Kopi Gayo | 85000.00 | 90000.00 | 94.4
Madu Hutan | 55000.00 | 90000.00 | 61.1
Teh Hijau | 40000.00 | 90000.00 | 44.4
Teh Melati | 35000.00 | 90000.00 | 38.9
Kacang Goreng | 25000.00 | 90000.00 | 27.8
Keripik Singkong | 20000.00 | 90000.00 | 22.2
Susu Kental Manis | 18000.00 | 90000.00 | 20.0
Gula Merah | 15000.00 | 90000.00 | 16.7
Beras Pandan Wangi | 13000.00 | 90000.00 | 14.4
(10 rows)
nama | harga | rerata_toko | posisi
--------------------+----------+-------------+-----------------
Madu Hutan | 55000.00 | 39600.00 | di atas rerata
Gula Merah | 15000.00 | 39600.00 | di bawah rerata
Beras Pandan Wangi | 13000.00 | 39600.00 | di bawah rerata
(3 rows)Mari kita bedah query pertama baris per baris:
(SELECT max(harga) FROM produk) AS harga_termahal— scalar subquery di posisi kolom hasil. Nilainya 90000.00, tampil sama di semua baris karena dihitung sekali untuk seluruh query.round(p.harga * 100.0 / (SELECT max(harga) FROM produk), 1)— scalar subquery di dalam ekspresi aritmetika. Penyebutnya 90000.00, jadiKopi Torajamencapai 100.0 persen dari harga termahal danBeras Pandan Wangihanya 14.4 persen. Penulisan100.0(bukan100) menjaga pembagian tetap bernilai desimal, bukan terpotong ke bilangan bulat.ORDER BY p.harga DESC, p.nama— urutan menurun berdasar harga dengan nama sebagai pemecah seri; tanpa ini urutan baris tidak dijamin.
Query kedua memperlihatkan scalar subquery bekerja di tiga tempat sekaligus dalam satu query: sebagai kolom tampilan (rerata_toko bernilai 39600.00), sebagai kondisi di dalam CASE, dan tetap konsisten meski ditulis berulang. Hasilnya tiga produk Sembako terbelah rapi: Madu Hutan di atas rata-rata, Gula Merah dan Beras Pandan Wangi di bawahnya.
Scalar Subquery di WHERE #
Posisi kedua sudah Anda cicipi di chapter pengenalan; sekarang kita perlengkap dengan ekspresi di sisi kanan. Patokan boleh dioperasikan lebih dulu — rata-rata dikali 1.2, dibagi 2, digabung dengan coalesce() — sebelum dibandingkan dengan kolom:
-- Scalar subquery di klausa WHERE sebagai patokan perbandingan.
-- Ekspresi di sisi kanan boleh dihitung: rata-rata dikali 1.2.
SELECT p.nama, p.harga,
(SELECT round(avg(harga), 2) FROM produk) AS batas
FROM produk p
WHERE p.harga > (SELECT avg(harga) FROM produk) * 1.2
ORDER BY p.harga DESC;
-- Perbandingan searah sebaliknya: produk di bawah setengah rata-rata.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga < (SELECT avg(harga) FROM produk) / 2
ORDER BY p.harga;Output yang Dihasilkan:
nama | harga | batas
-------------+----------+----------
Kopi Toraja | 90000.00 | 39600.00
Kopi Gayo | 85000.00 | 39600.00
Madu Hutan | 55000.00 | 39600.00
(3 rows)
nama | harga
--------------------+----------
Beras Pandan Wangi | 13000.00
Gula Merah | 15000.00
Susu Kental Manis | 18000.00
(3 rows)Query pertama mencari produk yang melampaui 120 persen rata-rata toko — ambang 47520.00 — dan hanya Kopi Toraja, Kopi Gayo, serta Madu Hutan yang lolos. Query kedua membalik arah: setengah rata-rata (19800.00) menjadi langit-langit, menyisakan Beras Pandan Wangi, Gula Merah, dan Susu Kental Manis. Perhatikan kolom batas pada query pertama: menampilkan patokan di samping hasilnya adalah kebiasaan pelaporan yang bagus — pembaca laporan langsung tahu 47520.00 bukan angka karangan, melainkan rata-rata toko yang dihitung dari data yang sama.
Ketika Subquery Tidak Menemukan Baris #
Apa yang terjadi bila scalar subquery tidak menemukan baris sama sekali — misalnya kita mencari produk yang tidak ada namanya? Jawabannya bukan error, melainkan NULL. Perilaku ini konsisten dengan agregat: avg(), max(), dan kawan-kawannya atas himpunan kosong juga menghasilkan NULL (dibahas di chapter SUM dan AVG
):
-- Scalar subquery yang menghasilkan TIDAK ADA baris tidak error:
-- nilainya menjadi NULL, persis seperti agregat atas himpunan kosong.
SELECT (SELECT harga FROM produk WHERE nama = 'Kopi Luwak') AS harga_kopi_luwak,
(SELECT harga FROM produk WHERE nama = 'Kopi Gayo') AS harga_kopi_gayo;
-- Bukti bahwa sumber NULL-nya memang kosong: avg() tanpa baris = NULL.
SELECT avg(harga) AS rerata_elektronik
FROM produk
WHERE kategori = 'Elektronik';
-- NULL di posisi pembanding WHERE tidak pernah bernilai true,
-- sehingga hasil query menjadi nol baris -- bukan error.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga > (SELECT avg(harga) FROM produk WHERE kategori = 'Elektronik')
ORDER BY p.nama;Output yang Dihasilkan:
harga_kopi_luwak | harga_kopi_gayo
------------------+-----------------
| 85000.00
(1 row)
rerata_elektronik
-------------------
(1 row)
nama | harga
------+-------
(0 rows)Tiga hasil di atas menceritakan satu alur utuh. Query pertama: Kopi Luwak tidak ada di katalog, jadi kolom harga_kopi_luwak berisi NULL — sementara Kopi Gayo yang ada nilainya 85000.00. Query kedua memperlihatkan sumber NULL yang sama lewat jalur agregat: kategori Elektronik tidak memiliki satu produk pun, dan avg(harga) mengembalikan NULL. Query ketiga adalah gabungan keduanya sekaligus pengingat aturan NULL dari chapter Operator Perbandingan
: pembandingan dengan NULL menghasilkan UNKNOWN, UNKNOWN tidak lolos WHERE, dan hasil akhirnya nol baris tanpa pesan error sedikit pun — gejala “query diam-diam kosong” yang kerap membingungkan pemula.
Dua Pelanggaran yang Langsung Error #
Sekarang sengaja kita langgar dua syarat bentuknya. Pelanggaran pertama: subquery menghasilkan lebih dari satu baris. Kategori Minuman berisi lima produk, jadi subquery (SELECT p2.harga FROM produk p2 WHERE p2.kategori = 'Minuman') menghasilkan lima nilai sekaligus:
-- Scalar subquery hanya boleh menghasilkan SATU baris.
-- Kategori 'Minuman' berisi lima produk, jadi subquery di bawah
-- mengembalikan lima baris dan PostgreSQL menghentikan query.
SELECT p.nama,
(SELECT p2.harga FROM produk p2 WHERE p2.kategori = 'Minuman') AS harga_minuman
FROM produk p
WHERE p.nama = 'Kopi Gayo';Output yang Dihasilkan:
psql:error-dua-baris.sql:7: ERROR: more than one row returned by a subquery used as an expressionPesan lengkapnya: more than one row returned by a subquery used as an expression. Perhatikan bahwa error ini baru muncul saat query berjalan — filter luar p.nama = 'Kopi Gayo' memang hanya menyisakan satu baris hasil, tetapi subquery tetap dievaluasi apa adanya dan tetap menghasilkan lima baris. Pelanggaran kedua: satu baris tetapi dua kolom:
-- Scalar subquery juga hanya boleh menghasilkan SATU kolom.
-- Subquery di bawah menghasilkan dua kolom (harga dan stok)
-- meski barisnya tepat satu -- tetap ditolak.
SELECT p.nama,
(SELECT p2.harga, p2.stok FROM produk p2 WHERE p2.nama = 'Kopi Gayo') AS info
FROM produk p
WHERE p.nama = 'Kopi Gayo';Output yang Dihasilkan:
psql:error-dua-kolom.sql:7: ERROR: subquery must return only one column
LINE 2: (SELECT p2.harga, p2.stok FROM produk p2 WHERE p2.nam...
^Kali ini PostgreSQL menolak sejak tahap parsing dengan pesan subquery must return only one column — satu “sel” tidak bisa menampung dua nilai, jadi query ini tidak akan pernah sah bagaimanapun datanya. Perbedaan waktu kemunculan error kedua contoh ini penting: pelanggaran kolom tertangkap sejak awal, sedangkan pelanggaran baris bergantung pada isi tabel saat query dijalankan.
Scalar subquery menjawab kebutuhan “satu angka patokan”. Namun banyak pertanyaan penyaringan butuh bukan satu angka, melainkan satu daftar — daftar id yang pernah terjual, daftar nama yang sudah dipakai, daftar pasangan nilai. Untuk itu WHERE punya keluarga operator tersendiri: IN, ANY, dan ALL, beserta jebakan NOT IN yang legendaris. Chapter berikutnya membahasnya semua: Subquery dalam WHERE
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Tabel latihan chapter Scalar Subquery:
-- katalog produk Toko Maju Jaya (domain section Subquery dan CTE).
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
);
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);
SELECT id, nama, kategori, harga, stok FROM produk ORDER BY id;
error-dua-baris.sql
Unduh berkas-- Scalar subquery hanya boleh menghasilkan SATU baris.
-- Kategori 'Minuman' berisi lima produk, jadi subquery di bawah
-- mengembalikan lima baris dan PostgreSQL menghentikan query.
SELECT p.nama,
(SELECT p2.harga FROM produk p2 WHERE p2.kategori = 'Minuman') AS harga_minuman
FROM produk p
WHERE p.nama = 'Kopi Gayo';
error-dua-kolom.sql
Unduh berkas-- Scalar subquery juga hanya boleh menghasilkan SATU kolom.
-- Subquery di bawah menghasilkan dua kolom (harga dan stok)
-- meski barisnya tepat satu -- tetap ditolak.
SELECT p.nama,
(SELECT p2.harga, p2.stok FROM produk p2 WHERE p2.nama = 'Kopi Gayo') AS info
FROM produk p
WHERE p.nama = 'Kopi Gayo';
nol-baris-null.sql
Unduh berkas-- Scalar subquery yang menghasilkan TIDAK ADA baris tidak error:
-- nilainya menjadi NULL, persis seperti agregat atas himpunan kosong.
SELECT (SELECT harga FROM produk WHERE nama = 'Kopi Luwak') AS harga_kopi_luwak,
(SELECT harga FROM produk WHERE nama = 'Kopi Gayo') AS harga_kopi_gayo;
-- Bukti bahwa sumber NULL-nya memang kosong: avg() tanpa baris = NULL.
SELECT avg(harga) AS rerata_elektronik
FROM produk
WHERE kategori = 'Elektronik';
-- NULL di posisi pembanding WHERE tidak pernah bernilai true,
-- sehingga hasil query menjadi nol baris -- bukan error.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga > (SELECT avg(harga) FROM produk WHERE kategori = 'Elektronik')
ORDER BY p.nama;
pembanding-tetap.sql
Unduh berkas-- Scalar subquery di daftar SELECT: hasilnya dipakai layaknya
-- konstanta yang dihitung sekali sebelum baris demi baris diproses.
-- Patokan: harga produk termahal (90000.00).
SELECT p.nama,
p.harga,
(SELECT max(harga) FROM produk) AS harga_termahal,
round(p.harga * 100.0 / (SELECT max(harga) FROM produk), 1) AS persen_dari_termahal
FROM produk p
ORDER BY p.harga DESC, p.nama;
-- Subquery yang sama bisa dipakai berulang kali dalam satu query:
-- sekali sebagai pembanding mutlak, sekali sebagai penyebut persentase.
SELECT p.nama,
p.harga,
(SELECT round(avg(harga), 2) FROM produk) AS rerata_toko,
CASE
WHEN p.harga > (SELECT avg(harga) FROM produk) THEN 'di atas rerata'
ELSE 'di bawah rerata'
END AS posisi
FROM produk p
WHERE p.kategori = 'Sembako'
ORDER BY p.harga DESC;
skalar-di-where.sql
Unduh berkas-- Scalar subquery di klausa WHERE sebagai patokan perbandingan.
-- Ekspresi di sisi kanan boleh dihitung: rata-rata dikali 1.2.
SELECT p.nama, p.harga,
(SELECT round(avg(harga), 2) FROM produk) AS batas
FROM produk p
WHERE p.harga > (SELECT avg(harga) FROM produk) * 1.2
ORDER BY p.harga DESC;
-- Perbandingan searah sebaliknya: produk di bawah setengah rata-rata.
SELECT p.nama, p.harga
FROM produk p
WHERE p.harga < (SELECT avg(harga) FROM produk) / 2
ORDER BY p.harga;