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:

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

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

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

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

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

  1. count(*) FILTER (WHERE ...) — baris tersaring di tingkat agregat, count(*) menghitung yang lolos.
  2. count(CASE WHEN ... THEN 1 END) — semua baris masuk agregat, CASE mengubah yang tidak cocok menjadi NULL, dan aturan “agregat mengabaikan NULL” (yang kita kenal dari chapter COUNT ) menuntaskan penyaringannya.
AspekFILTERCASE WHEN di dalam agregat
Keterbacaankondisi tampil sebagai satu klausa ringkaskondisi terbungkus ekspresi bertingkat
DukunganSQL:2003; PostgreSQL 9.4 ke atasberlaku di hampir semua database, termasuk yang lama
Bentuk countcount(*) FILTER (WHERE ...)count(CASE WHEN ... THEN 1 END)
Bentuk sumsum(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:

sql filter-null.sql
-- 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:

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

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

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;