Sepanjang section ini satu pola kebutuhan muncul berulang kali dengan wajah berbeda: “hitung semua baris, tetapi untuk angka yang ini hanya baris QRIS”, “jumlahkan nilai per kota, tetapi kolomnya harus per kategori”, “berapa persentase transaksi tunai di tiap kota”. Kebutuhan itu — agregat yang hanya menelan baris pilihan — punya tiga penyelesaian yang telah kita sentuh satu per satu: CASE WHEN di dalam agregat, klausa FILTER, dan subquery dengan WHERE. Chapter penutup ini menarik ketiganya ke satu meja, membandingkan keterbacaan, portabilitas, dan perilaku mereka terhadap kelompok kosong, lalu merangkai teknik menjadi dua resep paling dicari di dunia laporan: pivot manual dan baris ringkasan satu query.
Tabel Latihan #
Tabel penjualan Toko Maju Jaya menemani kita terakhir kali — dua belas transaksi dengan kombinasi kategori, kota, dan metode pembayaran yang cukup beragam untuk semua demo:
-- tabel latihan: penjualan Toko Maju Jaya, Januari-Februari 2026
CREATE TABLE penjualan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
kategori text NOT NULL,
produk text NOT NULL,
kota text NOT NULL,
jumlah integer NOT NULL,
harga_satuan numeric(12,2) NOT NULL,
metode_bayar text
);
INSERT INTO penjualan (id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar) VALUES
( 1, DATE '2026-01-05', 'Minuman', 'Kopi Gayo', 'Jakarta', 10, 85000.00, 'tunai'),
( 2, DATE '2026-01-08', 'Makanan', 'Biskuit', 'Jakarta', 20, 25000.00, 'transfer'),
( 3, DATE '2026-01-12', 'Alat Tulis', 'Pulpen', 'Bandung', 15, 8000.00, 'qris'),
( 4, DATE '2026-01-15', 'Minuman', 'Kopi Gayo', 'Bandung', 8, 85000.00, 'transfer'),
( 5, DATE '2026-01-20', 'Makanan', 'Biskuit', 'Surabaya', 12, 25000.00, NULL),
( 6, DATE '2026-01-25', 'Alat Tulis', 'Pulpen', 'Jakarta', 30, 8000.00, 'tunai'),
( 7, DATE '2026-02-02', 'Minuman', 'Kopi Gayo', 'Surabaya', 12, 85000.00, 'qris'),
( 8, DATE '2026-02-06', 'Makanan', 'Biskuit', 'Bandung', 25, 25000.00, 'transfer'),
( 9, DATE '2026-02-10', 'Alat Tulis', 'Pulpen', 'Surabaya', 6, 8000.00, NULL),
(10, DATE '2026-02-14', 'Minuman', 'Kopi Gayo', 'Jakarta', 5, 85000.00, 'qris'),
(11, DATE '2026-02-18', 'Makanan', 'Biskuit', 'Jakarta', 18, 25000.00, 'tunai'),
(12, DATE '2026-02-24', 'Alat Tulis', 'Pulpen', 'Bandung', 9, 8000.00, 'qris');
SELECT id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar
FROM penjualan
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 12
id | tanggal | kategori | produk | kota | jumlah | harga_satuan | metode_bayar
----+------------+------------+-----------+----------+--------+--------------+--------------
1 | 2026-01-05 | Minuman | Kopi Gayo | Jakarta | 10 | 85000.00 | tunai
2 | 2026-01-08 | Makanan | Biskuit | Jakarta | 20 | 25000.00 | transfer
3 | 2026-01-12 | Alat Tulis | Pulpen | Bandung | 15 | 8000.00 | qris
4 | 2026-01-15 | Minuman | Kopi Gayo | Bandung | 8 | 85000.00 | transfer
5 | 2026-01-20 | Makanan | Biskuit | Surabaya | 12 | 25000.00 |
6 | 2026-01-25 | Alat Tulis | Pulpen | Jakarta | 30 | 8000.00 | tunai
7 | 2026-02-02 | Minuman | Kopi Gayo | Surabaya | 12 | 85000.00 | qris
8 | 2026-02-06 | Makanan | Biskuit | Bandung | 25 | 25000.00 | transfer
9 | 2026-02-10 | Alat Tulis | Pulpen | Surabaya | 6 | 8000.00 |
10 | 2026-02-14 | Minuman | Kopi Gayo | Jakarta | 5 | 85000.00 | qris
11 | 2026-02-18 | Makanan | Biskuit | Jakarta | 18 | 25000.00 | tunai
12 | 2026-02-24 | Alat Tulis | Pulpen | Bandung | 9 | 8000.00 | qris
(12 rows)Karena chapter ini bersifat rangkuman, setiap teknik akan tetap diberi pengantar singkat — tetapi bila Anda melompat langsung ke sini, chapter FILTER Clause dan HAVING memuat bedah lengkapnya.
CASE WHEN di Dalam Agregat #
Pola tertuanya: bungkus kondisi dalam CASE, kembalikan nilai hanya pada baris yang cocok, dan biarkan agregat mengabaikan NULL sisanya. Aturan “agregat melewatkan NULL” dari chapter COUNT
yang bekerja di balik layar:
-- CASE di dalam agregat: hitung nilai selain NULL saja
-- pivot hitungan: kategori menjadi kolom
SELECT kota,
count(CASE WHEN kategori = 'Minuman' THEN 1 END) AS minuman,
count(CASE WHEN kategori = 'Makanan' THEN 1 END) AS makanan,
count(CASE WHEN kategori = 'Alat Tulis' THEN 1 END) AS alat_tulis
FROM penjualan
GROUP BY kota
ORDER BY kota;
-- tanpa ELSE: kelompok tanpa kecocokan menghasilkan NULL, bukan 0
SELECT kota,
sum(CASE WHEN metode_bayar = 'transfer'
THEN jumlah * harga_satuan END) AS transfer_tanpa_else,
sum(CASE WHEN metode_bayar = 'transfer'
THEN jumlah * harga_satuan
ELSE 0 END) AS transfer_dengan_else
FROM penjualan
GROUP BY kota
ORDER BY kota;Output yang Dihasilkan:
kota | minuman | makanan | alat_tulis
----------+---------+---------+------------
Bandung | 1 | 1 | 2
Jakarta | 2 | 2 | 1
Surabaya | 1 | 1 | 1
(3 rows)
kota | transfer_tanpa_else | transfer_dengan_else
----------+---------------------+----------------------
Bandung | 1305000.00 | 1305000.00
Jakarta | 500000.00 | 500000.00
Surabaya | | 0
(3 rows)Query pertama menunjukkan pola count(CASE WHEN ... THEN 1 END): baris yang tidak cocok menghasilkan NULL dari CASE (karena tidak ada cabang ELSE), dan count mengabaikannya — Bandung terhitung satu transaksi Minuman, dua Alat Tulis. Query kedua membedah gotcha sum: Surabaya tidak punya transaksi transfer sama sekali, sehingga versi tanpa ELSE menjumlahkan kumpulan NULL dan menghasilkan NULL, sedangkan versi dengan ELSE 0 menghasilkan 0. Untuk laporan, ELSE 0 umumnya yang Anda mau — sel kosong harus bernilai nol, bukan NULL yang tampak seperti data hilang.
Tiga Pendekatan Berdampingan #
Pertanyaan yang sama — berapa transaksi QRIS per kategori? — dijawab tiga cara. Perhatikan hasilnya baris demi baris, karena ada satu perbedaan yang tidak tampak dari sintaks:
-- tiga cara menjawab pertanyaan yang sama: berapa transaksi qris per kategori?
-- cara 1: CASE di dalam agregat
SELECT kategori,
count(CASE WHEN metode_bayar = 'qris' THEN 1 END) AS transaksi_qris
FROM penjualan
GROUP BY kategori
ORDER BY kategori;
-- cara 2: klausa FILTER
SELECT kategori,
count(*) FILTER (WHERE metode_bayar = 'qris') AS transaksi_qris
FROM penjualan
GROUP BY kategori
ORDER BY kategori;
-- cara 3: subquery dengan WHERE (kelompok nol hilang!)
SELECT kategori,
count(*) AS transaksi_qris
FROM penjualan
WHERE metode_bayar = 'qris'
GROUP BY kategori
ORDER BY kategori;Output yang Dihasilkan:
kategori | transaksi_qris
------------+----------------
Alat Tulis | 2
Makanan | 0
Minuman | 2
(3 rows)
kategori | transaksi_qris
------------+----------------
Alat Tulis | 2
Makanan | 0
Minuman | 2
(3 rows)
kategori | transaksi_qris
------------+----------------
Alat Tulis | 2
Minuman | 2
(2 rows)Cara 1 dan cara 2 menghasilkan tiga baris — Alat Tulis 2, Makanan 0, Minuman 2. Cara 3 hanya menghasilkan dua baris: Makanan hilang total. Mekanismenya lugas: subquery menyaring baris QRIS sebelum pengelompokan, dan kategori yang tidak memiliki satu pun baris QRIS tidak pernah sempat membentuk kelompok. CASE dan FILTER mengelompokkan dulu baru menyaring di dalam agregat, sehingga kelompok kosong tetap hadir dengan angka nol. Perbandingan menyeluruh ketiganya:
| Aspek | CASE di agregat | FILTER | Subquery + WHERE |
|---|---|---|---|
| Keterbacaan | baik bila kondisi pendek; memburuk saat bertingkat | paling ringkas, kondisi berdiri sendiri | paling intuitif, seperti query biasa |
| Status standar | SQL lama, didukung semua database | SQL:2003; PostgreSQL 9.4+ | SQL lama, didukung semua database |
| Satu pemindaian | ya — beberapa agregat sekali jalan | ya — beberapa agregat sekali jalan | tidak — satu query per angka |
| Kelompok kosong | tetap tampil, bernilai 0 | tetap tampil, bernilai 0 | hilang tanpa jejak |
| Cocok untuk | portabilitas maksimum, database lama | default di PostgreSQL modern | laporan satu kondisi tanpa kelompok nol |
Pivot Manual: Baris Menjadi Kolom #
Pivot (atau crosstab) mengubah data memanjang menjadi matriks: nilai unik satu kolom dijadikan deretan kolom. SQL standar tidak punya klausa PIVOT seperti milik beberapa database lain, tetapi agregasi bersyarat menuntaskan polanya dengan elegan — setiap kolom hasil adalah satu agregat bersyarat:
-- pivot manual: baris menjadi kolom lewat agregasi bersyarat
-- hitungan transaksi: kota sebagai baris, kategori sebagai kolom
SELECT kota,
count(CASE WHEN kategori = 'Minuman' THEN 1 END) AS minuman,
count(CASE WHEN kategori = 'Makanan' THEN 1 END) AS makanan,
count(CASE WHEN kategori = 'Alat Tulis' THEN 1 END) AS alat_tulis,
count(*) AS total
FROM penjualan
GROUP BY kota
ORDER BY kota;
-- nilai rupiah: ELSE 0 menjaga sel yang kosong tetap bernilai nol
SELECT kota,
sum(CASE WHEN kategori = 'Minuman'
THEN jumlah * harga_satuan ELSE 0 END) AS minuman,
sum(CASE WHEN kategori = 'Makanan'
THEN jumlah * harga_satuan ELSE 0 END) AS makanan,
sum(CASE WHEN kategori = 'Alat Tulis'
THEN jumlah * harga_satuan ELSE 0 END) AS alat_tulis,
sum(jumlah * harga_satuan) AS total
FROM penjualan
GROUP BY kota
ORDER BY kota;Output yang Dihasilkan:
kota | minuman | makanan | alat_tulis | total
----------+---------+---------+------------+-------
Bandung | 1 | 1 | 2 | 4
Jakarta | 2 | 2 | 1 | 5
Surabaya | 1 | 1 | 1 | 3
(3 rows)
kota | minuman | makanan | alat_tulis | total
----------+------------+-----------+------------+------------
Bandung | 680000.00 | 625000.00 | 192000.00 | 1497000.00
Jakarta | 1275000.00 | 950000.00 | 240000.00 | 2465000.00
Surabaya | 1020000.00 | 300000.00 | 48000.00 | 1368000.00
(3 rows)Query pertama memutar hitungan transaksi: tiga baris kota, tiga kolom kategori, satu kolom total tanpa kondisi sebagai penjaga. Query kedua memutar nilai rupiah — matriks 3×3 lengkap dengan total barisnya: Minuman Jakarta 1.275.000, Makanan Bandung 625.000, dan seterusnya, berujung total keseluruhan 5.330.000. Resepnya berhenti di tiga langkah: (1) pilih kolom pemutar untuk GROUP BY — itu barisnya; (2) satu agregat bersyarat per nilai yang ingin menjadi kolom; (3) ELSE 0 pada sum agar sel kosong bernilai nol. Batas pola ini juga nyata: daftar kolom ditulis tangan, sehingga kategori baru menuntut query disunting — pivot statis, bukan dinamis.
Resep Praktis: Laporan Satu Query #
Penutup yang mempertemukan semuanya — satu query laporan per kota yang memuat agregat polos, FILTER, round, dan aritmetika antar agregat dalam satu SELECT:
-- resep laporan satu query: FILTER + round + aritmetika antar agregat
SELECT kota,
count(*) AS transaksi,
count(*) FILTER (WHERE metode_bayar = 'qris') AS transaksi_qris,
round(100.0 * count(*) FILTER (WHERE metode_bayar = 'qris') / count(*), 1)
AS persen_qris,
sum(jumlah * harga_satuan) AS total_nilai,
round(avg(jumlah), 2) AS rata_item
FROM penjualan
GROUP BY kota
ORDER BY kota;Output yang Dihasilkan:
kota | transaksi | transaksi_qris | persen_qris | total_nilai | rata_item
----------+-----------+----------------+-------------+-------------+-----------
Bandung | 4 | 2 | 50.0 | 1497000.00 | 14.25
Jakarta | 5 | 1 | 20.0 | 2465000.00 | 16.60
Surabaya | 3 | 1 | 33.3 | 1368000.00 | 10.00
(3 rows)Enam kolom dari satu kali pemindaian: jumlah transaksi, irisan QRIS-nya, persentase QRIS (100.0 * mendorong pembagian ke tipe numer agar desimalnya tidak terpotong), total nilai rupiah, dan rata-rata item. Bandung mencatat 50% transaksi QRIS sementara Jakarta 20% — angka yang akan sulit dibaca bila diambil dari query terpisah. Kolom persen_qris juga mengingatkan aturan GROUP BY dari awal section: setiap kolom non-agregat harus masuk GROUP BY, dan sisanya dibangun dari ekspresi agregat.
Agregasi data kini tuntas sebagai satu kotak perkakas: dari count dan sum di chapter-chapter awal, GROUP BY/HAVING untuk mengelompokkan dan menyaring, FILTER serta CASE untuk agregasi bersyarat, hingga GROUPING SETS, ROLLUP, dan CUBE untuk subtotal multidimensi. Tetapi semua latihan kita selama ini memakai satu tabel — padahal kekuatan sejati database relational justru muncul saat data dari beberapa tabel digabungkan. Section berikutnya membuka wilayah itu: JOIN
.
Referensi #
- Aggregate Functions — perilaku agregat terhadap NULL dan klausa FILTER.
- SELECT (SQL Reference) — sintaks GROUP BY, HAVING, dan GROUPING SETS.
- tablefunc
— dokumentasi fungsi
crosstab()untuk pivot dinamis.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- tabel latihan: penjualan Toko Maju Jaya, Januari-Februari 2026
CREATE TABLE penjualan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
kategori text NOT NULL,
produk text NOT NULL,
kota text NOT NULL,
jumlah integer NOT NULL,
harga_satuan numeric(12,2) NOT NULL,
metode_bayar text
);
INSERT INTO penjualan (id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar) VALUES
( 1, DATE '2026-01-05', 'Minuman', 'Kopi Gayo', 'Jakarta', 10, 85000.00, 'tunai'),
( 2, DATE '2026-01-08', 'Makanan', 'Biskuit', 'Jakarta', 20, 25000.00, 'transfer'),
( 3, DATE '2026-01-12', 'Alat Tulis', 'Pulpen', 'Bandung', 15, 8000.00, 'qris'),
( 4, DATE '2026-01-15', 'Minuman', 'Kopi Gayo', 'Bandung', 8, 85000.00, 'transfer'),
( 5, DATE '2026-01-20', 'Makanan', 'Biskuit', 'Surabaya', 12, 25000.00, NULL),
( 6, DATE '2026-01-25', 'Alat Tulis', 'Pulpen', 'Jakarta', 30, 8000.00, 'tunai'),
( 7, DATE '2026-02-02', 'Minuman', 'Kopi Gayo', 'Surabaya', 12, 85000.00, 'qris'),
( 8, DATE '2026-02-06', 'Makanan', 'Biskuit', 'Bandung', 25, 25000.00, 'transfer'),
( 9, DATE '2026-02-10', 'Alat Tulis', 'Pulpen', 'Surabaya', 6, 8000.00, NULL),
(10, DATE '2026-02-14', 'Minuman', 'Kopi Gayo', 'Jakarta', 5, 85000.00, 'qris'),
(11, DATE '2026-02-18', 'Makanan', 'Biskuit', 'Jakarta', 18, 25000.00, 'tunai'),
(12, DATE '2026-02-24', 'Alat Tulis', 'Pulpen', 'Bandung', 9, 8000.00, 'qris');
SELECT id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar
FROM penjualan
ORDER BY id;
case-dalam-agregat.sql
Unduh berkas-- CASE di dalam agregat: hitung nilai selain NULL saja
-- pivot hitungan: kategori menjadi kolom
SELECT kota,
count(CASE WHEN kategori = 'Minuman' THEN 1 END) AS minuman,
count(CASE WHEN kategori = 'Makanan' THEN 1 END) AS makanan,
count(CASE WHEN kategori = 'Alat Tulis' THEN 1 END) AS alat_tulis
FROM penjualan
GROUP BY kota
ORDER BY kota;
-- tanpa ELSE: kelompok tanpa kecocokan menghasilkan NULL, bukan 0
SELECT kota,
sum(CASE WHEN metode_bayar = 'transfer'
THEN jumlah * harga_satuan END) AS transfer_tanpa_else,
sum(CASE WHEN metode_bayar = 'transfer'
THEN jumlah * harga_satuan
ELSE 0 END) AS transfer_dengan_else
FROM penjualan
GROUP BY kota
ORDER BY kota;
pivot-manual.sql
Unduh berkas-- pivot manual: baris menjadi kolom lewat agregasi bersyarat
-- hitungan transaksi: kota sebagai baris, kategori sebagai kolom
SELECT kota,
count(CASE WHEN kategori = 'Minuman' THEN 1 END) AS minuman,
count(CASE WHEN kategori = 'Makanan' THEN 1 END) AS makanan,
count(CASE WHEN kategori = 'Alat Tulis' THEN 1 END) AS alat_tulis,
count(*) AS total
FROM penjualan
GROUP BY kota
ORDER BY kota;
-- nilai rupiah: ELSE 0 menjaga sel yang kosong tetap bernilai nol
SELECT kota,
sum(CASE WHEN kategori = 'Minuman'
THEN jumlah * harga_satuan ELSE 0 END) AS minuman,
sum(CASE WHEN kategori = 'Makanan'
THEN jumlah * harga_satuan ELSE 0 END) AS makanan,
sum(CASE WHEN kategori = 'Alat Tulis'
THEN jumlah * harga_satuan ELSE 0 END) AS alat_tulis,
sum(jumlah * harga_satuan) AS total
FROM penjualan
GROUP BY kota
ORDER BY kota;
resep-praktis.sql
Unduh berkas-- resep laporan satu query: FILTER + round + aritmetika antar agregat
SELECT kota,
count(*) AS transaksi,
count(*) FILTER (WHERE metode_bayar = 'qris') AS transaksi_qris,
round(100.0 * count(*) FILTER (WHERE metode_bayar = 'qris') / count(*), 1)
AS persen_qris,
sum(jumlah * harga_satuan) AS total_nilai,
round(avg(jumlah), 2) AS rata_item
FROM penjualan
GROUP BY kota
ORDER BY kota;
tiga-pendekatan.sql
Unduh berkas-- tiga cara menjawab pertanyaan yang sama: berapa transaksi qris per kategori?
-- cara 1: CASE di dalam agregat
SELECT kategori,
count(CASE WHEN metode_bayar = 'qris' THEN 1 END) AS transaksi_qris
FROM penjualan
GROUP BY kategori
ORDER BY kategori;
-- cara 2: klausa FILTER
SELECT kategori,
count(*) FILTER (WHERE metode_bayar = 'qris') AS transaksi_qris
FROM penjualan
GROUP BY kategori
ORDER BY kategori;
-- cara 3: subquery dengan WHERE (kelompok nol hilang!)
SELECT kategori,
count(*) AS transaksi_qris
FROM penjualan
WHERE metode_bayar = 'qris'
GROUP BY kategori
ORDER BY kategori;