Posisi subquery yang pertama dan kedua — di daftar SELECT dan di WHERE — menyumbangkan nilai bagi query luar. Posisi ketiga jauh lebih berkuasa: subquery yang duduk di klausa FROM menyumbangkan baris, dan baris adalah bahan baku utama SQL. Bentuk ini disebut derived table (tabel turunan): subquery berjalan lebih dulu, hasilnya menjadi tabel sementara, lalu query luar memperlakukannya persis seperti tabel sungguhan — bisa dipilih kolomnya, disaring, diurutkan, bahkan digabungkan dengan JOIN. Kekuatan ini membuka pola yang mustahil ditulis datar, seperti menyaring hasil agregat dengan WHERE biasa atau menggabungkan ringkasan per-kelompok ke tabel induk tanpa menggelembungkan angkanya — pola grouped-join yang akan kita bangun bertahap di chapter ini.
Tabel Latihan: produk dan penjualan #
Domain tetap section ini kembali hadir utuh: 10 produk Toko Maju Jaya dan 10 transaksi penjualan Januari–Februari 2026, termasuk baris penjualan id 8 dengan produk_id NULL. Data penjualan akan kita agregatkan di dalam derived table lalu digabungkan kembali ke katalog produk — pola inti chapter ini:
-- Tabel latihan chapter Subquery dalam FROM:
-- 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)Derived Table: Subquery yang Menjadi Tabel #
Bentuk dasarnya persis seperti tabel biasa di FROM, hanya saja sumbernya subquery dan wajib diberi nama melalui alias. Subquery di bawah mengelompokkan produk per kategori; hasil tiga barisnya menjadi tabel sementara bernama ringkas yang tinggal dipilih dan diurutkan:
-- Derived table: subquery di klausa FROM, hasilnya menjadi
-- tabel sementara bernama 'ringkas' untuk query luar.
-- Laporan jumlah produk dan rata-rata harga per kategori.
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;
-- Keunggulan derived table: hasil agregat menjadi kolom biasa,
-- sehingga bisa disaring dengan WHERE biasa -- sesuatu yang tidak
-- bisa dilakukan WHERE terhadap agregat di query GROUP BY.
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
WHERE ringkas.rerata > 25000
ORDER BY ringkas.kategori;Output yang Dihasilkan:
kategori | jumlah_produk | rerata
----------+---------------+----------
Camilan | 2 | 22500.00
Minuman | 5 | 53600.00
Sembako | 3 | 27666.67
(3 rows)
kategori | jumlah_produk | rerata
----------+---------------+----------
Minuman | 5 | 53600.00
Sembako | 3 | 27666.67
(2 rows)Query pertama menghasilkan tiga baris — Camilan dengan 2 produk dan rata-rata 22500.00, Minuman dengan 5 produk dan 53600.00, Sembako dengan 3 produk dan 27666.67. Query kedua memperlihatkan alasan derived table begitu berguna: WHERE ringkas.rerata > 25000 menyaring langsung terhadap kolom hasil agregat. Di query GROUP BY yang datar, pernyataan seperti itu mustahil karena WHERE berjalan sebelum pengelompokan — tugas itu jatuh ke HAVING (dibahas di chapter HAVING
). Derived table mengangkat agregat menjadi kolom biasa, dan kolom biasa boleh disaring WHERE, diurutkan, atau dipakai dalam perhitungan apa pun.
Dua kebiasaan penulisan di file itu perlu dicatat. Pertama, setiap kolom hasil agregat di dalam subquery diberi alias (AS jumlah_produk, AS rerata) — tanpa alias, kolom agregat mendapat nama generik seperti count dan round yang tidak bisa dirujuk dengan jelas dari luar. Kedua, tabel sementara selalu diberi alias (AS ringkas) sehingga kolomnya bisa dikualifikasi — ringkas.kategori — sama seperti alias tabel di chapter INNER JOIN
.
Wajibkah Alias? Fakta untuk PostgreSQL 16+ #
Banyak materi lama menyebut alias derived table sebagai kewajiban mutlak — dan itu benar untuk PostgreSQL 15 ke bawah. Sejak PostgreSQL 16, aturannya melunak: subquery di FROM boleh ditulis tanpa alias. Kedua query berikut berjalan identik di server PostgreSQL 17:
-- Sejak PostgreSQL 16, subquery di FROM boleh ditulis TANPA alias.
-- Query berikut berjalan di PostgreSQL 16 dan 17.
SELECT kategori, count(*) AS jumlah_produk
FROM (SELECT kategori FROM produk WHERE harga > 20000)
GROUP BY kategori
ORDER BY kategori;
-- Namun dengan alias, tabel sementara punya nama yang bisa dirujuk --
-- kolomnya bisa dikualifikasi dan strukturnya lebih mudah dibaca.
SELECT bersih.kategori, count(*) AS jumlah_produk
FROM (SELECT kategori FROM produk WHERE harga > 20000) AS bersih
GROUP BY bersih.kategori
ORDER BY bersih.kategori;Output yang Dihasilkan:
kategori | jumlah_produk
----------+---------------
Camilan | 1
Minuman | 4
Sembako | 1
(3 rows)
kategori | jumlah_produk
----------+---------------
Camilan | 1
Minuman | 4
Sembako | 1
(3 rows)Kedua query menghitung jumlah produk berharga di atas 20000.00 per kategori — Minuman 4, Camilan 1, Sembako 1 — dan hasilnya persis sama. Dokumentasi resmi PostgreSQL mencatat posisinya dengan gamblang: standar SQL tetap mewajibkan alias untuk subquery di FROM, PostgreSQL (sejak versi 16) mengizinkan alias dilewatkan, tetapi menulisnya tetap disebut kebiasaan yang baik demi portabilitas.
Pola Grouped-Join: Agregat Dulu, Gabung Kemudian #
Inilah resep paling sering dipakai dari subquery di FROM: meringkas tabel sisi-banyak (penjualan) di dalam derived table, lalu menggabungkan ringkasannya ke tabel sisi-satu (produk). Tiga query berikut membandingkan tiga varian resep itu:
-- Pola grouped-join: agregatkan tabel sisi-banyak DI DALAM subquery,
-- lalu gabungkan hasilnya ke tabel sisi-satu.
-- Query A: agregasi dulu (derived table), join kemudian.
SELECT p.nama, p.kategori, r.transaksi, r.total_unit
FROM produk p
INNER JOIN (SELECT produk_id,
count(*) AS transaksi,
sum(jumlah) AS total_unit
FROM penjualan
WHERE produk_id IS NOT NULL
GROUP BY produk_id) AS r ON r.produk_id = p.id
ORDER BY r.total_unit DESC, p.nama;
-- Query B: join dulu, agregasi kemudian -- hasilnya sama di kasus ini.
SELECT p.nama, p.kategori, count(*) AS transaksi, sum(pn.jumlah) AS total_unit
FROM penjualan pn
INNER JOIN produk p ON p.id = pn.produk_id
GROUP BY p.id, p.nama, p.kategori
ORDER BY total_unit DESC, p.nama;
-- Query C: padankan LEFT JOIN agar produk yang belum terjual tetap tampil.
SELECT p.nama, coalesce(r.total_unit, 0) AS total_unit
FROM produk p
LEFT JOIN (SELECT produk_id, sum(jumlah) AS total_unit
FROM penjualan
WHERE produk_id IS NOT NULL
GROUP BY produk_id) AS r ON r.produk_id = p.id
ORDER BY coalesce(r.total_unit, 0) DESC, p.nama;Output yang Dihasilkan:
nama | kategori | transaksi | total_unit
--------------------+----------+-----------+------------
Gula Merah | Sembako | 2 | 70
Kopi Gayo | Minuman | 3 | 40
Teh Melati | Minuman | 1 | 25
Beras Pandan Wangi | Sembako | 1 | 22
Teh Hijau | Minuman | 1 | 15
Kopi Toraja | Minuman | 1 | 8
(6 rows)
nama | kategori | transaksi | total_unit
--------------------+----------+-----------+------------
Gula Merah | Sembako | 2 | 70
Kopi Gayo | Minuman | 3 | 40
Teh Melati | Minuman | 1 | 25
Beras Pandan Wangi | Sembako | 1 | 22
Teh Hijau | Minuman | 1 | 15
Kopi Toraja | Minuman | 1 | 8
(6 rows)
nama | total_unit
--------------------+------------
Gula Merah | 70
Kopi Gayo | 40
Teh Melati | 25
Beras Pandan Wangi | 22
Teh Hijau | 15
Kopi Toraja | 8
Kacang Goreng | 0
Keripik Singkong | 0
Madu Hutan | 0
Susu Kental Manis | 0
(10 rows)Mari kita bedah ketiganya satu per satu:
- Query A (agregasi dulu) — derived table
rmerangkum penjualan menjadi satu baris per produk:Gula Merahterjual 70 unit dalam 2 transaksi,Kopi Gayo40 unit dalam 3 transaksi, dan seterusnya.INNER JOINkemudian menempelkan ringkasan itu ke kolom katalog yang dibutuhkan (nama,kategori) — setiap produk muncul tepat satu kali. - Query B (join dulu) — urutan dibalik: seluruh penjualan digabungkan ke produk, baru
GROUP BYmeringkas. Hasil angkanya sama karena join dilakukan tepat pada kolom pengelompok (produk_id): tiap baris penjualan berkontribusi sekali. PenulisanGROUP BY p.id, p.nama, p.kategorimenyertakan seluruh kolom terpilih sesuai aturan dari chapter GROUP BY . - Query C (versi LEFT JOIN) — INNER JOIN membuang empat produk yang belum pernah terjual; menggantinya
LEFT JOINmempertahankan seluruh katalog, dengancoalesce(r.total_unit, 0)merapikan nilaiNULLmenjadi angka nol yang layak dibaca.
Bila Query A dan B hasilnya sama, kapan pola agregasi-dulu unggul? Ketika ringkasan harus digabungkan dengan lebih dari satu tabel sekaligus. Menggabungkan penjualan langsung ke dua tabel sisi-banyak — misalnya juga ke tabel pembayaran cicilan — membuat satu transaksi terduplikasi per cicilan dan sum() menggelembung; jebakan duplikasi join ini dibahas tuntas di section JOIN
. Dengan pola grouped-join, tiap sisi banyak diagregatkan dulu dalam derived table-nya sendiri, lalu ringkasan yang sudah satu-baris-per-kunci digabungkan dengan aman.
Derived Table Tidak Melihat Kolom Query Luar #
Aturan penting yang sering mengejutkan: derived table bersifat uncorrelated secara bawaan — subquery di FROM tidak boleh merujuk kolom tabel lain yang berada di query luar. Percobaan berikut mencoba menghitung rata-rata harga per kategori dari tiap baris produk, dan PostgreSQL menolaknya:
-- Derived table secara bawaan TIDAK boleh merujuk kolom query luar.
-- Subquery di bawah mencoba memakai p.kategori milik tabel produk --
-- PostgreSQL menolaknya dan mengusulkan penyelesaiannya lewat HINT.
SELECT p.nama, r.rerata
FROM produk p
CROSS JOIN (SELECT round(avg(p2.harga), 2) AS rerata
FROM produk p2
WHERE p2.kategori = p.kategori) AS r
ORDER BY p.nama;Output yang Dihasilkan:
psql:kolom-luar.sql:9: ERROR: invalid reference to FROM-clause entry for table "p"
LINE 5: WHERE p2.kategori = p.kategori) AS r
^
DETAIL: There is an entry for table "p", but it cannot be referenced from this part of the query.
HINT: To reference that table, you must mark this subquery with LATERAL.Pesan invalid reference to FROM-clause entry for table "p" menegaskan bahwa p ada di klausa FROM tetapi tidak boleh dirujuk dari posisi subquery. Yang istimewa kali ini adalah baris HINT: PostgreSQL sendiri memberi tahu penyelesaiannya — To reference that table, you must mark this subquery with LATERAL. Penyelesaiannya memang satu kata kunci:
-- Perbaikan dari error sebelumnya: tandai subquery dengan LATERAL.
-- Kini subquery boleh membaca kolom tabel yang berada di kirinya,
-- dan dievaluasi ulang untuk setiap baris produk (per kategori).
SELECT p.nama, p.kategori, p.harga, r.rerata
FROM produk p
CROSS JOIN LATERAL (SELECT round(avg(p2.harga), 2) AS rerata
FROM produk p2
WHERE p2.kategori = p.kategori) AS r
ORDER BY p.kategori, p.nama;Output yang Dihasilkan:
nama | kategori | harga | rerata
--------------------+----------+----------+----------
Kacang Goreng | Camilan | 25000.00 | 22500.00
Keripik Singkong | Camilan | 20000.00 | 22500.00
Kopi Gayo | Minuman | 85000.00 | 53600.00
Kopi Toraja | Minuman | 90000.00 | 53600.00
Susu Kental Manis | Minuman | 18000.00 | 53600.00
Teh Hijau | Minuman | 40000.00 | 53600.00
Teh Melati | Minuman | 35000.00 | 53600.00
Beras Pandan Wangi | Sembako | 13000.00 | 27666.67
Gula Merah | Sembako | 15000.00 | 27666.67
Madu Hutan | Sembako | 55000.00 | 27666.67
(10 rows)Dengan LATERAL, subquery di FROM menjadi correlated — kolom p.kategori terlihat dari dalam, dan rata-rata dihitung ulang mengikuti kategori tiap baris: 53600.00 untuk produk Minuman, 27666.67 untuk Sembako, 22500.00 untuk Camilan. LATERAL join punya daya jauh di atas itu — mengurutkan data per baris luar, memanggil fungsi khusus, membangkitkan deret — dan pembahasannya mendapat chapter tersendiri di LATERAL Join
.
Derived table mengajari kita memandang subquery sebagai tahapan: hitung ringkasan, beri nama, pakai di tahap berikutnya. Namun ketika tahapan itu bertambah banyak, tanda kurung yang bersarang dalam-dalam mulai menggerogoti keterbacaan — dan di titik itu SQL punya alat bernama WITH. Chapter berikutnya membahasnya: Common Table Expressions
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Tabel latihan chapter Subquery dalam FROM:
-- 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;
derived-dasar.sql
Unduh berkas-- Derived table: subquery di klausa FROM, hasilnya menjadi
-- tabel sementara bernama 'ringkas' untuk query luar.
-- Laporan jumlah produk dan rata-rata harga per kategori.
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;
-- Keunggulan derived table: hasil agregat menjadi kolom biasa,
-- sehingga bisa disaring dengan WHERE biasa -- sesuatu yang tidak
-- bisa dilakukan WHERE terhadap agregat di query GROUP BY.
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
WHERE ringkas.rerata > 25000
ORDER BY ringkas.kategori;
grouped-join.sql
Unduh berkas-- Pola grouped-join: agregatkan tabel sisi-banyak DI DALAM subquery,
-- lalu gabungkan hasilnya ke tabel sisi-satu.
-- Query A: agregasi dulu (derived table), join kemudian.
SELECT p.nama, p.kategori, r.transaksi, r.total_unit
FROM produk p
INNER JOIN (SELECT produk_id,
count(*) AS transaksi,
sum(jumlah) AS total_unit
FROM penjualan
WHERE produk_id IS NOT NULL
GROUP BY produk_id) AS r ON r.produk_id = p.id
ORDER BY r.total_unit DESC, p.nama;
-- Query B: join dulu, agregasi kemudian -- hasilnya sama di kasus ini.
SELECT p.nama, p.kategori, count(*) AS transaksi, sum(pn.jumlah) AS total_unit
FROM penjualan pn
INNER JOIN produk p ON p.id = pn.produk_id
GROUP BY p.id, p.nama, p.kategori
ORDER BY total_unit DESC, p.nama;
-- Query C: padankan LEFT JOIN agar produk yang belum terjual tetap tampil.
SELECT p.nama, coalesce(r.total_unit, 0) AS total_unit
FROM produk p
LEFT JOIN (SELECT produk_id, sum(jumlah) AS total_unit
FROM penjualan
WHERE produk_id IS NOT NULL
GROUP BY produk_id) AS r ON r.produk_id = p.id
ORDER BY coalesce(r.total_unit, 0) DESC, p.nama;
kolom-luar.sql
Unduh berkas-- Derived table secara bawaan TIDAK boleh merujuk kolom query luar.
-- Subquery di bawah mencoba memakai p.kategori milik tabel produk --
-- PostgreSQL menolaknya dan mengusulkan penyelesaiannya lewat HINT.
SELECT p.nama, r.rerata
FROM produk p
CROSS JOIN (SELECT round(avg(p2.harga), 2) AS rerata
FROM produk p2
WHERE p2.kategori = p.kategori) AS r
ORDER BY p.nama;
lateral.sql
Unduh berkas-- Perbaikan dari error sebelumnya: tandai subquery dengan LATERAL.
-- Kini subquery boleh membaca kolom tabel yang berada di kirinya,
-- dan dievaluasi ulang untuk setiap baris produk (per kategori).
SELECT p.nama, p.kategori, p.harga, r.rerata
FROM produk p
CROSS JOIN LATERAL (SELECT round(avg(p2.harga), 2) AS rerata
FROM produk p2
WHERE p2.kategori = p.kategori) AS r
ORDER BY p.kategori, p.nama;
tanpa-alias.sql
Unduh berkas-- Sejak PostgreSQL 16, subquery di FROM boleh ditulis TANPA alias.
-- Query berikut berjalan di PostgreSQL 16 dan 17.
SELECT kategori, count(*) AS jumlah_produk
FROM (SELECT kategori FROM produk WHERE harga > 20000)
GROUP BY kategori
ORDER BY kategori;
-- Namun dengan alias, tabel sementara punya nama yang bisa dirujuk --
-- kolomnya bisa dikualifikasi dan strukturnya lebih mudah dibaca.
SELECT bersih.kategori, count(*) AS jumlah_produk
FROM (SELECT kategori FROM produk WHERE harga > 20000) AS bersih
GROUP BY bersih.kategori
ORDER BY bersih.kategori;