Pertanyaan laporan nyata jarang berbentuk satu angka. Satu baris dasbor sering harus menjawab sekaligus: berapa total transaksi, berapa di antaranya dibayar QRIS, berapa nilai penjualan Jakarta, dan berapa rata-rata item per metode pembayaran. Cara lama menulis query per angka lalu menggabungkannya di aplikasi — atau menumpuk CASE WHEN di dalam setiap agregat. Sejak PostgreSQL 9.4 ada jalan ketiga yang lebih bersih: klausa FILTER, bagian standar SQL:2003 yang menempel pada fungsi agregat dan memilih baris mana saja yang ikut dihitung. Chapter ini membedah sintaksnya, kesetaraannya dengan pola CASE WHEN, dan keuntungan satu-kali-pemindaian yang membuatnya unggul untuk laporan.
Tabel Latihan #
Kita lanjut memakai tabel penjualan Toko Maju Jaya dengan dua belas transaksi Januari–Februari 2026. Kolom metode_bayar memuat tiga nilai berbeda plus dua baris NULL — keragaman ini penting karena FILTER akan kita arahkan ke kolom tersebut:
-- 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)Struktur tabel identik dengan chapter HAVING — inilah keuntungan domain latihan yang konsisten: Anda bisa memusatkan perhatian pada klausa baru tanpa harus mempelajari data baru.
Bentuk Dasar: count(*) FILTER #
Sintaksnya menempel langsung pada agregat: agregat(...) FILTER (WHERE kondisi). Hanya baris yang memenuhi kondisi yang ikut dihitung; baris lain seolah tidak ada bagi agregat itu:
-- FILTER menyeleksi baris mana yang ikut agregat, semuanya dalam satu query
-- komposisi kategori: 4 kolom angka dari satu kali pemindaian
SELECT count(*) AS semua,
count(*) FILTER (WHERE kategori = 'Minuman') AS minuman,
count(*) FILTER (WHERE kategori = 'Makanan') AS makanan,
count(*) FILTER (WHERE kategori = 'Alat Tulis') AS alat_tulis
FROM penjualan;
-- komposisi metode pembayaran: logika yang sama, kolom berbeda
SELECT count(*) FILTER (WHERE metode_bayar = 'tunai') AS tunai,
count(*) FILTER (WHERE metode_bayar = 'transfer') AS transfer,
count(*) FILTER (WHERE metode_bayar = 'qris') AS qris
FROM penjualan;Output yang Dihasilkan:
semua | minuman | makanan | alat_tulis
-------+---------+---------+------------
12 | 4 | 4 | 4
(1 row)
tunai | transfer | qris
-------+----------+------
3 | 3 | 4
(1 row)Query pertama menghasilkan satu baris berisi empat angka: semua menghitung dua belas baris tanpa saringan, tiga kolom berikutnya masing-masing menghitung empat — komposisi kategori yang seimbang. Query kedua membedah kolom metode_bayar: tiga tunai, tiga transfer, empat QRIS. Perhatikan bahwa count(*) FILTER (WHERE metode_bayar = 'tunai') berperilaku seperti count(*) pada tabel yang sudah difilter WHERE metode_bayar = 'tunai' — hanya jauh lebih murah karena tabel cukup dipindai sekali untuk semua angka.
FILTER untuk sum dan avg #
FILTER bukan milik count saja — setiap fungsi agregat menerimanya. Di sinilah keuntungan satu-kali-pemindaian benar-benar terasa: tiga subtotal kota yang tadinya menuntut tiga query terpisah kini lahir dari satu SELECT tanpa GROUP BY:
-- FILTER bekerja untuk sum dan avg, bukan hanya count
-- nilai penjualan per kota dihitung dalam satu query, tanpa GROUP BY
SELECT sum(jumlah * harga_satuan) FILTER (WHERE kota = 'Jakarta') AS jakarta,
sum(jumlah * harga_satuan) FILTER (WHERE kota = 'Bandung') AS bandung,
sum(jumlah * harga_satuan) FILTER (WHERE kota = 'Surabaya') AS surabaya
FROM penjualan;
-- rata-rata item per transaksi menurut metode pembayaran
SELECT round(avg(jumlah) FILTER (WHERE metode_bayar = 'tunai'), 2) AS rata_tunai,
round(avg(jumlah) FILTER (WHERE metode_bayar = 'transfer'), 2) AS rata_transfer,
round(avg(jumlah) FILTER (WHERE metode_bayar = 'qris'), 2) AS rata_qris
FROM penjualan;Output yang Dihasilkan:
jakarta | bandung | surabaya
------------+------------+------------
2465000.00 | 1497000.00 | 1368000.00
(1 row)
rata_tunai | rata_transfer | rata_qris
------------+---------------+-----------
19.33 | 17.67 | 10.25
(1 row)Query pertama menempatkan subtotal tiga kota berdampingan: Jakarta 2.465.000, Bandung 1.497.000, Surabaya 1.368.000 rupiah. Bandingkan dengan alternatif klasiknya — tiga query SELECT sum(...) WHERE kota = ..., atau satu query GROUP BY kota yang menghasilkan tiga baris alih-alih satu baris tiga kolom. FILTER memberi kendali penuh atas bentuk keluaran. Query kedua memperlihatkan bahwa avg dengan FILTER hanya membagi dengan jumlah baris yang lolos saringan — transaksi tunai (10, 30, 18 item) menghasilkan rata-rata 19,33, bukan rata-rata seluruh tabel.
FILTER vs CASE WHEN di Dalam Agregat #
Sebelum FILTER ada, pola baku untuk agregasi bersyarat adalah CASE WHEN di dalam argumen agregat. Keduanya menghasilkan angka yang persis sama:
-- FILTER dan CASE WHEN di dalam agregat menghasilkan angka yang sama
-- hitung transaksi qris dengan dua gaya penulisan
SELECT count(*) FILTER (WHERE metode_bayar = 'qris') AS via_filter,
count(CASE WHEN metode_bayar = 'qris' THEN 1 END) AS via_case
FROM penjualan;
-- jumlahkan item Jakarta dengan dua gaya penulisan
SELECT sum(jumlah) FILTER (WHERE kota = 'Jakarta') AS via_filter,
sum(CASE WHEN kota = 'Jakarta' THEN jumlah END) AS via_case
FROM penjualan;Output yang Dihasilkan:
via_filter | via_case
------------+----------
4 | 4
(1 row)
via_filter | via_case
------------+----------
83 | 83
(1 row)Kedua pasangan kolom bernilai sama: 4 transaksi QRIS dan 83 item Jakarta. Mekanisme kesetaraannya berbeda — count(CASE WHEN ... THEN 1 END) mengevaluasi CASE untuk setiap baris, menghasilkan 1 pada baris cocok dan NULL pada baris lain, lalu count mengabaikan NULL. FILTER memotong langkah perantara itu: baris yang tidak cocok tidak pernah sampai ke agregat. Bedah singkatnya:
count(*) FILTER (WHERE ...)— baris tersaring di tingkat agregat,count(*)menghitung yang lolos.count(CASE WHEN ... THEN 1 END)— semua baris masuk agregat,CASEmengubah yang tidak cocok menjadi NULL, dan aturan “agregat mengabaikan NULL” (yang kita kenal dari chapter COUNT ) menuntaskan penyaringannya.
| Aspek | FILTER | CASE WHEN di dalam agregat |
|---|---|---|
| Keterbacaan | kondisi tampil sebagai satu klausa ringkas | kondisi terbungkus ekspresi bertingkat |
| Dukungan | SQL:2003; PostgreSQL 9.4 ke atas | berlaku di hampir semua database, termasuk yang lama |
Bentuk count | count(*) FILTER (WHERE ...) | count(CASE WHEN ... THEN 1 END) |
Bentuk sum | sum(x) FILTER (WHERE ...) | sum(CASE WHEN ... THEN x END) — perlu ELSE 0 agar sel kosong bernilai nol |
NULL dan Logika Tiga Nilai di FILTER #
Kondisi FILTER tunduk pada logika tiga nilai SQL yang sama dengan WHERE. Baris dengan kondisi NULL — termasuk perbandingan yang menyentuh kolom NULL — tidak ikut agregat:
-- FILTER mewarisi logika tiga nilai SQL: baris dengan kondisi NULL tidak ikut
-- qris + bukan_qris + kosong = 12: baris NULL tidak masuk dua filter pertama
SELECT count(*) FILTER (WHERE metode_bayar = 'qris') AS qris,
count(*) FILTER (WHERE metode_bayar <> 'qris') AS bukan_qris,
count(*) FILTER (WHERE metode_bayar IS NULL) AS belum_dibayar
FROM penjualan;
-- FILTER dengan IS NULL untuk menghitung nilai transaksi yang belum dibayar
SELECT sum(jumlah * harga_satuan) FILTER (WHERE metode_bayar IS NULL) AS nilai_belum_dibayar
FROM penjualan;Output yang Dihasilkan:
qris | bukan_qris | belum_dibayar
------+------------+---------------
4 | 6 | 2
(1 row)
nilai_belum_dibayar
---------------------
348000.00
(1 row)Tiga angka query pertama berjumlah tepat dua belas: qris menangkap empat baris, bukan_qris menangkap enam baris, dan dua baris NULL tersingkir dari dua filter pertama karena NULL <> 'qris' bernilai UNKNOWN — hanya IS NULL yang menangkapnya. Query kedua memanfaatkan sifat yang sama untuk keperluan bisnis: menghitung nilai rupiah transaksi yang metode pembayarannya belum dikonfirmasi, hasilnya 348.000 dari transaksi id 5 dan id 9.
FILTER Berdampingan dengan GROUP BY #
FILTER tidak menggantikan GROUP BY; keduanya bekerja pada sumbu berbeda. GROUP BY membagi baris menjadi kelompok, lalu FILTER menyeleksi baris di dalam tiap kelompok sebelum agregat menghitung:
-- FILTER hidup berdampingan dengan GROUP BY: saringan terjadi di dalam tiap kelompok
-- komposisi metode pembayaran per kategori
SELECT kategori,
count(*) AS transaksi,
count(*) FILTER (WHERE metode_bayar = 'tunai') AS tunai,
count(*) FILTER (WHERE metode_bayar = 'transfer') AS transfer,
count(*) FILTER (WHERE metode_bayar = 'qris') AS qris,
count(*) FILTER (WHERE metode_bayar IS NULL) AS belum_dibayar
FROM penjualan
GROUP BY kategori
ORDER BY kategori;Output yang Dihasilkan:
kategori | transaksi | tunai | transfer | qris | belum_dibayar
------------+-----------+-------+----------+------+---------------
Alat Tulis | 4 | 1 | 0 | 2 | 1
Makanan | 4 | 1 | 2 | 0 | 1
Minuman | 4 | 1 | 1 | 2 | 0
(3 rows)Setiap baris hasil adalah satu kelompok kategori, dan lima kolom angkanya dihitung dari baris kelompok itu saja: keempat transaksi Alat Tulis terurai menjadi dua QRIS, satu tunai, satu belum dibayar, sementara Makanan mencatat dua transfer. Lima angka per kelompok dalam satu pemindaian — pola yang di dunia laporan disebut agregasi bersyarat, dan yang akan kita rangkai menjadi matriks lengkap di chapter penutup section ini.
FILTER menuntut satu level penyaringan: baris masuk atau tidak ke dalam satu agregat. Laporan bertingkat menuntut lebih dari itu — detail, subtotal, dan total keseluruhan dalam satu hasil. Untuk itu PostgreSQL menyediakan GROUPING SETS, ROLLUP, dan CUBE, yang kita mulai di chapter berikutnya: Grouping Sets
.
Referensi #
- Aggregate Functions — dokumentasi resmi klausa FILTER.
- SELECT (SQL Reference) — sintaks SELECT dan daftar klausa.
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;
filter-dasar.sql
Unduh berkas-- FILTER menyeleksi baris mana yang ikut agregat, semuanya dalam satu query
-- komposisi kategori: 4 kolom angka dari satu kali pemindaian
SELECT count(*) AS semua,
count(*) FILTER (WHERE kategori = 'Minuman') AS minuman,
count(*) FILTER (WHERE kategori = 'Makanan') AS makanan,
count(*) FILTER (WHERE kategori = 'Alat Tulis') AS alat_tulis
FROM penjualan;
-- komposisi metode pembayaran: logika yang sama, kolom berbeda
SELECT count(*) FILTER (WHERE metode_bayar = 'tunai') AS tunai,
count(*) FILTER (WHERE metode_bayar = 'transfer') AS transfer,
count(*) FILTER (WHERE metode_bayar = 'qris') AS qris
FROM penjualan;
filter-group-by.sql
Unduh berkas-- FILTER hidup berdampingan dengan GROUP BY: saringan terjadi di dalam tiap kelompok
-- komposisi metode pembayaran per kategori
SELECT kategori,
count(*) AS transaksi,
count(*) FILTER (WHERE metode_bayar = 'tunai') AS tunai,
count(*) FILTER (WHERE metode_bayar = 'transfer') AS transfer,
count(*) FILTER (WHERE metode_bayar = 'qris') AS qris,
count(*) FILTER (WHERE metode_bayar IS NULL) AS belum_dibayar
FROM penjualan
GROUP BY kategori
ORDER BY kategori;
filter-null.sql
Unduh berkas-- FILTER mewarisi logika tiga nilai SQL: baris dengan kondisi NULL tidak ikut
-- qris + bukan_qris + kosong = 12: baris NULL tidak masuk dua filter pertama
SELECT count(*) FILTER (WHERE metode_bayar = 'qris') AS qris,
count(*) FILTER (WHERE metode_bayar <> 'qris') AS bukan_qris,
count(*) FILTER (WHERE metode_bayar IS NULL) AS belum_dibayar
FROM penjualan;
-- FILTER dengan IS NULL untuk menghitung nilai transaksi yang belum dibayar
SELECT sum(jumlah * harga_satuan) FILTER (WHERE metode_bayar IS NULL) AS nilai_belum_dibayar
FROM penjualan;
filter-sum-avg.sql
Unduh berkas-- FILTER bekerja untuk sum dan avg, bukan hanya count
-- nilai penjualan per kota dihitung dalam satu query, tanpa GROUP BY
SELECT sum(jumlah * harga_satuan) FILTER (WHERE kota = 'Jakarta') AS jakarta,
sum(jumlah * harga_satuan) FILTER (WHERE kota = 'Bandung') AS bandung,
sum(jumlah * harga_satuan) FILTER (WHERE kota = 'Surabaya') AS surabaya
FROM penjualan;
-- rata-rata item per transaksi menurut metode pembayaran
SELECT round(avg(jumlah) FILTER (WHERE metode_bayar = 'tunai'), 2) AS rata_tunai,
round(avg(jumlah) FILTER (WHERE metode_bayar = 'transfer'), 2) AS rata_transfer,
round(avg(jumlah) FILTER (WHERE metode_bayar = 'qris'), 2) AS rata_qris
FROM penjualan;
filter-vs-case.sql
Unduh berkas-- FILTER dan CASE WHEN di dalam agregat menghasilkan angka yang sama
-- hitung transaksi qris dengan dua gaya penulisan
SELECT count(*) FILTER (WHERE metode_bayar = 'qris') AS via_filter,
count(CASE WHEN metode_bayar = 'qris' THEN 1 END) AS via_case
FROM penjualan;
-- jumlahkan item Jakarta dengan dua gaya penulisan
SELECT sum(jumlah) FILTER (WHERE kota = 'Jakarta') AS via_filter,
sum(CASE WHEN kota = 'Jakarta' THEN jumlah END) AS via_case
FROM penjualan;