Laporan penjualan Toko Maju Jaya sering meminta dua hal sekaligus: rincian bulan per bulan dan angka pembanding seperti total semester atau rata-rata per produk. Query agregat biasa dengan GROUP BY tidak bisa memenuhi permintaan itu karena GROUP BY meratakan enam belas baris menjadi tiga baris ringkasan — begitu total dihitung, rinciannya hilang. Window function menyelesaikan konflik tersebut: angka agregat tetap dihitung, tetapi setiap baris rincian tetap berdiri sendiri. Chapter ini membangun fondasi konsepnya — apa itu jendela (window), bagaimana fungsi agregat berubah perilaku begitu diberi klausa OVER, dan mengapa window function tidak boleh ditulis di WHERE.
Tabel Latihan #
Kita memakai satu domain yang sama untuk seluruh section ini: tabel penjualan_bulanan berisi penjualan bulanan per produk sepanjang semester I 2026. Kolom bulan menyimpan tanggal pertama tiap bulan, kolom kanal membedakan penjualan langsung di toko dan lewat online, dan kolom jumlah adalah banyak unit yang terjual:
-- tabel latihan: penjualan bulanan per produk Toko Maju Jaya, semester I 2026
CREATE TABLE penjualan_bulanan (
id integer PRIMARY KEY,
produk text NOT NULL,
bulan date NOT NULL,
kanal text NOT NULL,
jumlah integer NOT NULL
);
INSERT INTO penjualan_bulanan (id, produk, bulan, kanal, jumlah) VALUES
( 1, 'Kopi Gayo', DATE '2026-01-01', 'toko', 120),
( 2, 'Teh Melati', DATE '2026-01-01', 'toko', 80),
( 3, 'Kopi Gayo', DATE '2026-02-01', 'online', 135),
( 4, 'Teh Melati', DATE '2026-02-01', 'toko', 95),
( 5, 'Gula Pasir', DATE '2026-02-01', 'toko', 60),
( 6, 'Gula Pasir', DATE '2026-03-01', 'toko', 70),
( 7, 'Kopi Gayo', DATE '2026-03-01', 'toko', 128),
( 8, 'Teh Melati', DATE '2026-03-01', 'toko', 80),
( 9, 'Gula Pasir', DATE '2026-04-01', 'toko', 70),
(10, 'Kopi Gayo', DATE '2026-04-01', 'online', 150),
(11, 'Teh Melati', DATE '2026-04-01', 'toko', 110),
(12, 'Kopi Gayo', DATE '2026-05-01', 'online', 150),
(13, 'Gula Pasir', DATE '2026-05-01', 'toko', 55),
(14, 'Kopi Gayo', DATE '2026-06-01', 'online', 160),
(15, 'Teh Melati', DATE '2026-06-01', 'online', 130),
(16, 'Gula Pasir', DATE '2026-06-01', 'toko', 75);
SELECT id, produk, bulan, kanal, jumlah
FROM penjualan_bulanan
ORDER BY bulan, produk;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 16
id | produk | bulan | kanal | jumlah
----+------------+------------+--------+--------
1 | Kopi Gayo | 2026-01-01 | toko | 120
2 | Teh Melati | 2026-01-01 | toko | 80
5 | Gula Pasir | 2026-02-01 | toko | 60
3 | Kopi Gayo | 2026-02-01 | online | 135
4 | Teh Melati | 2026-02-01 | toko | 95
6 | Gula Pasir | 2026-03-01 | toko | 70
7 | Kopi Gayo | 2026-03-01 | toko | 128
8 | Teh Melati | 2026-03-01 | toko | 80
9 | Gula Pasir | 2026-04-01 | toko | 70
10 | Kopi Gayo | 2026-04-01 | online | 150
11 | Teh Melati | 2026-04-01 | toko | 110
13 | Gula Pasir | 2026-05-01 | toko | 55
12 | Kopi Gayo | 2026-05-01 | online | 150
16 | Gula Pasir | 2026-06-01 | toko | 75
14 | Kopi Gayo | 2026-06-01 | online | 160
15 | Teh Melati | 2026-06-01 | online | 130
(16 rows)Data ini sengaja tidak rapi, karena data nyata juga tidak rapi. Ada nilai jumlah yang sama pada bulan berbeda (Kopi Gayo terjual 150 unit pada April dan Mei) — seri seperti ini penting untuk chapter ranking. Ada juga bulan yang hilang: Gula Pasir tidak punya baris Januari dan Teh Melati tidak punya baris Mei — celah ini akan kita pakai saat membandingkan baris dengan tetangganya di chapter LAG dan LEAD
.
Masalah Inti: Agregat Meratakan Baris #
Aturannya sederhana: GROUP BY meringkas banyak baris menjadi sedikit baris. sum, avg, dan count menghitung satu nilai per kelompok, lalu PostgreSQL membuang baris-baris mentahnya. Mekanisme inilah yang membuat laporan gabungan “rincian plus ringkasan” mustahil ditulis dalam satu query agregat biasa — kita terpaksa menjalankan dua query atau merangkai UNION ALL.
Window function membalik logika itu. Fungsi agregat yang sudah Anda kenal dari chapter Fungsi Agregat
berubah menjadi window function begitu ditambah klausa OVER: agregat tetap dihitung atas sekelompok baris, tetapi hasilnya ditempelkan ke setiap baris, bukan menjadi satu baris ringkasan. Perhatikan perbandingan berikut:
-- agregat biasa meratakan baris; window function mempertahankannya
-- (a) GROUP BY: hasil 3 baris, satu baris ringkasan per produk
SELECT produk,
sum(jumlah) AS total,
round(avg(jumlah), 1) AS rata_rata,
count(*) AS bulan_tercatat
FROM penjualan_bulanan
GROUP BY produk
ORDER BY produk;
-- (b) fungsi agregat + OVER: hasil tetap 16 baris,
-- total per produk muncul sebagai kolom tambahan di setiap baris
SELECT bulan,
produk,
jumlah,
sum(jumlah) OVER (PARTITION BY produk) AS total_produk,
round(avg(jumlah) OVER (PARTITION BY produk), 1) AS rata_produk,
count(*) OVER (PARTITION BY produk) AS bulan_tercatat
FROM penjualan_bulanan
ORDER BY bulan, produk;Output yang Dihasilkan:
produk | total | rata_rata | bulan_tercatat
------------+-------+-----------+----------------
Gula Pasir | 330 | 66.0 | 5
Kopi Gayo | 843 | 140.5 | 6
Teh Melati | 495 | 99.0 | 5
(3 rows)
bulan | produk | jumlah | total_produk | rata_produk | bulan_tercatat
------------+------------+--------+--------------+-------------+----------------
2026-01-01 | Kopi Gayo | 120 | 843 | 140.5 | 6
2026-01-01 | Teh Melati | 80 | 495 | 99.0 | 5
2026-02-01 | Gula Pasir | 60 | 330 | 66.0 | 5
2026-02-01 | Kopi Gayo | 135 | 843 | 140.5 | 6
2026-02-01 | Teh Melati | 95 | 495 | 99.0 | 5
2026-03-01 | Gula Pasir | 70 | 330 | 66.0 | 5
2026-03-01 | Kopi Gayo | 128 | 843 | 140.5 | 6
2026-03-01 | Teh Melati | 80 | 495 | 99.0 | 5
2026-04-01 | Gula Pasir | 70 | 330 | 66.0 | 5
2026-04-01 | Kopi Gayo | 150 | 843 | 140.5 | 6
2026-04-01 | Teh Melati | 110 | 495 | 99.0 | 5
2026-05-01 | Gula Pasir | 55 | 330 | 66.0 | 5
2026-05-01 | Kopi Gayo | 150 | 843 | 140.5 | 6
2026-06-01 | Gula Pasir | 75 | 330 | 66.0 | 5
2026-06-01 | Kopi Gayo | 160 | 843 | 140.5 | 6
2026-06-01 | Teh Melati | 130 | 495 | 99.0 | 5
(16 rows)Query (a) mengembalikan tepat 3 baris — satu per produk — karena GROUP BY produk melipat tiap kelompok menjadi satu baris. Query (b) mengembalikan 16 baris yang sama persis dengan isi tabel, hanya kini dengan tiga kolom tambahan. Nilai total_produk untuk semua baris Kopi Gayo adalah 843, untuk Teh Melati 495, untuk Gula Pasir 330 — angka yang identik dengan hasil query (a), hanya cara menyajikannya yang berbeda. Argumen PARTITION BY produk di dalam OVER adalah penentu kelompoknya; kita bedah klausa ini tuntas di chapter OVER dan PARTITION BY
.
Mental Model: Kolom Tambahan per Baris #
Cara tercepat memahami window function adalah membayangkan setiap pemanggilan sebagai kolom tambahan yang dihitung per baris. Setiap kolom window punya jendelanya sendiri — satu query boleh mencampur jendela yang berbeda-beda tanpa saling mengganggu:
-- mental model "kolom tambahan per baris": tiga jendela berbeda dalam satu query
SELECT bulan,
produk,
jumlah,
count(*) OVER () AS total_baris,
sum(jumlah) OVER () AS total_semester,
sum(jumlah) OVER (PARTITION BY produk) AS total_produk,
round(avg(jumlah) OVER (PARTITION BY kanal), 1) AS rata_kanal
FROM penjualan_bulanan
ORDER BY bulan, produk;Output yang Dihasilkan:
bulan | produk | jumlah | total_baris | total_semester | total_produk | rata_kanal
------------+------------+--------+-------------+----------------+--------------+------------
2026-01-01 | Kopi Gayo | 120 | 16 | 1668 | 843 | 85.7
2026-01-01 | Teh Melati | 80 | 16 | 1668 | 495 | 85.7
2026-02-01 | Gula Pasir | 60 | 16 | 1668 | 330 | 85.7
2026-02-01 | Kopi Gayo | 135 | 16 | 1668 | 843 | 145.0
2026-02-01 | Teh Melati | 95 | 16 | 1668 | 495 | 85.7
2026-03-01 | Gula Pasir | 70 | 16 | 1668 | 330 | 85.7
2026-03-01 | Kopi Gayo | 128 | 16 | 1668 | 843 | 85.7
2026-03-01 | Teh Melati | 80 | 16 | 1668 | 495 | 85.7
2026-04-01 | Gula Pasir | 70 | 16 | 1668 | 330 | 85.7
2026-04-01 | Kopi Gayo | 150 | 16 | 1668 | 843 | 145.0
2026-04-01 | Teh Melati | 110 | 16 | 1668 | 495 | 85.7
2026-05-01 | Gula Pasir | 55 | 16 | 1668 | 330 | 85.7
2026-05-01 | Kopi Gayo | 150 | 16 | 1668 | 843 | 145.0
2026-06-01 | Gula Pasir | 75 | 16 | 1668 | 330 | 85.7
2026-06-01 | Kopi Gayo | 160 | 16 | 1668 | 843 | 145.0
2026-06-01 | Teh Melati | 130 | 16 | 1668 | 495 | 145.0
(16 rows)Mari kita bedah kolom-kolom windownya:
count(*) OVER ():OVER()kosong berarti jendelanya seluruh tabel, sehingga semua baris menerima nilai 16 — banyaknya baris yang dilihat fungsi ini.sum(jumlah) OVER (): jendela yang sama, fungsi berbeda — semua baris menerima 1668, total unit sepanjang semester.sum(jumlah) OVER (PARTITION BY produk): jendela dipersempit per produk; nilai berganti mengikuti produk pada baris tersebut.round(avg(jumlah) OVER (PARTITION BY kanal), 1): jendela keempat sama sekali berbeda — dibagi menurut kanal, menghasilkan 85.7 untuk baristokodan 145.0 untuk barisonline.
Alur transformasinya dapat dipetakan seperti ini:
Diagram di atas menegaskan dua sisi konsep sekaligus: PARTITION BY membelah tabel menjadi jendela-jendela yang saling terpisah, dan hasil agregat setiap jendela kembali ke baris asalnya — jumlah baris keluaran selalu sama dengan jumlah baris masukan.
Window Function Dilarang di WHERE #
Window function dihitung pada tahap SELECT, setelah baris-baris lolos saringan WHERE, pengelompokan GROUP BY, dan penyaringan HAVING. Konsekuensinya: WHERE tidak mungkin menyaring berdasarkan hasil window function, karena saat WHERE dievaluasi, hasil itu belum ada. PostgreSQL menolaknya secara eksplisit — mari kita buktikan:
-- window function tidak boleh dipakai di WHERE: error yang diharapkan
SELECT bulan, produk, jumlah
FROM penjualan_bulanan
WHERE sum(jumlah) OVER (PARTITION BY produk) > 500
ORDER BY bulan, produk;Output yang Dihasilkan:
psql:window-di-where-error.sql:5: ERROR: window functions are not allowed in WHERE
LINE 3: WHERE sum(jumlah) OVER (PARTITION BY produk) > 500
^Query di atas mencoba menampilkan hanya baris-baris produk dengan total di atas 500 unit. PostgreSQL membalas dengan ERROR: window functions are not allowed in WHERE — pesan yang konsisten dengan urutan eksekusi: window function hidup setelah WHERE selesai. Pola perbaikannya adalah menghitung window function lebih dulu di query dalam, lalu menyaring hasilnya dari luar:
-- cara benar menyaring hasil window: bungkus dengan derived table, saring di luar
SELECT bulan, produk, jumlah, total_produk
FROM (
SELECT bulan, produk, jumlah,
sum(jumlah) OVER (PARTITION BY produk) AS total_produk
FROM penjualan_bulanan
) AS t
WHERE total_produk > 500
ORDER BY bulan, produk;Output yang Dihasilkan:
bulan | produk | jumlah | total_produk
------------+-----------+--------+--------------
2026-01-01 | Kopi Gayo | 120 | 843
2026-02-01 | Kopi Gayo | 135 | 843
2026-03-01 | Kopi Gayo | 128 | 843
2026-04-01 | Kopi Gayo | 150 | 843
2026-05-01 | Kopi Gayo | 150 | 843
2026-06-01 | Kopi Gayo | 160 | 843
(6 rows)Mari kita bedah pola ini:
- Query dalam (derived table
t): menghitungsum(jumlah) OVER (PARTITION BY produk)untuk semua 16 baris dan menghasilkan kolomtotal_produk. WHERE total_produk > 500di query luar: saringan berjalan setelah kolom window tersedia — hanya Kopi Gayo (total 843) yang lolos, sehingga keluaran berisi 6 baris rincian bulan milik produk tersebut.ORDER BY bulan, produk: mengurutkan hasil akhir; klausa ini berada paling akhir sehingga bebas mereferensikan kolom mana pun.
Pola “derived table + saring di luar” ini akan muncul berulang kali — pola top-N per kelompok di chapter ROW_NUMBER dibangun di atasnya.
Sampai di sini, konsepnya sudah utuh: fungsi agregat plus OVER menjadi window function yang menempelkan hasil agregasi ke setiap baris tanpa meratakan apa pun. Namun OVER masih tampak seperti kotak ajaib — kapan isinya dikosongkan, kapan diisi PARTITION BY, dan apa gunanya ORDER BY di dalamnya? Chapter berikutnya, OVER dan PARTITION BY
, membuka kotak itu dan menunjukkan bahwa tiga kata kunci kecil tersebut mengendalikan seluruh perilaku window function Anda.
Referensi #
- Tutorial: Window Functions
— pengantar resmi konsep window function dengan contoh
OVERdanPARTITION BY. - Window Functions (SQL Reference) — daftar fungsi window dan aturan pemakaiannya.
- SELECT (SQL Reference)
— sintaks klausa
OVERdan posisinya dalam query.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
agregat-vs-window.sql
Unduh berkas-- agregat biasa meratakan baris; window function mempertahankannya
-- (a) GROUP BY: hasil 3 baris, satu baris ringkasan per produk
SELECT produk,
sum(jumlah) AS total,
round(avg(jumlah), 1) AS rata_rata,
count(*) AS bulan_tercatat
FROM penjualan_bulanan
GROUP BY produk
ORDER BY produk;
-- (b) fungsi agregat + OVER: hasil tetap 16 baris,
-- total per produk muncul sebagai kolom tambahan di setiap baris
SELECT bulan,
produk,
jumlah,
sum(jumlah) OVER (PARTITION BY produk) AS total_produk,
round(avg(jumlah) OVER (PARTITION BY produk), 1) AS rata_produk,
count(*) OVER (PARTITION BY produk) AS bulan_tercatat
FROM penjualan_bulanan
ORDER BY bulan, produk;
buat-tabel.sql
Unduh berkas-- tabel latihan: penjualan bulanan per produk Toko Maju Jaya, semester I 2026
CREATE TABLE penjualan_bulanan (
id integer PRIMARY KEY,
produk text NOT NULL,
bulan date NOT NULL,
kanal text NOT NULL,
jumlah integer NOT NULL
);
INSERT INTO penjualan_bulanan (id, produk, bulan, kanal, jumlah) VALUES
( 1, 'Kopi Gayo', DATE '2026-01-01', 'toko', 120),
( 2, 'Teh Melati', DATE '2026-01-01', 'toko', 80),
( 3, 'Kopi Gayo', DATE '2026-02-01', 'online', 135),
( 4, 'Teh Melati', DATE '2026-02-01', 'toko', 95),
( 5, 'Gula Pasir', DATE '2026-02-01', 'toko', 60),
( 6, 'Gula Pasir', DATE '2026-03-01', 'toko', 70),
( 7, 'Kopi Gayo', DATE '2026-03-01', 'toko', 128),
( 8, 'Teh Melati', DATE '2026-03-01', 'toko', 80),
( 9, 'Gula Pasir', DATE '2026-04-01', 'toko', 70),
(10, 'Kopi Gayo', DATE '2026-04-01', 'online', 150),
(11, 'Teh Melati', DATE '2026-04-01', 'toko', 110),
(12, 'Kopi Gayo', DATE '2026-05-01', 'online', 150),
(13, 'Gula Pasir', DATE '2026-05-01', 'toko', 55),
(14, 'Kopi Gayo', DATE '2026-06-01', 'online', 160),
(15, 'Teh Melati', DATE '2026-06-01', 'online', 130),
(16, 'Gula Pasir', DATE '2026-06-01', 'toko', 75);
SELECT id, produk, bulan, kanal, jumlah
FROM penjualan_bulanan
ORDER BY bulan, produk;
filter-benar.sql
Unduh berkas-- cara benar menyaring hasil window: bungkus dengan derived table, saring di luar
SELECT bulan, produk, jumlah, total_produk
FROM (
SELECT bulan, produk, jumlah,
sum(jumlah) OVER (PARTITION BY produk) AS total_produk
FROM penjualan_bulanan
) AS t
WHERE total_produk > 500
ORDER BY bulan, produk;
kolom-tambahan.sql
Unduh berkas-- mental model "kolom tambahan per baris": tiga jendela berbeda dalam satu query
SELECT bulan,
produk,
jumlah,
count(*) OVER () AS total_baris,
sum(jumlah) OVER () AS total_semester,
sum(jumlah) OVER (PARTITION BY produk) AS total_produk,
round(avg(jumlah) OVER (PARTITION BY kanal), 1) AS rata_kanal
FROM penjualan_bulanan
ORDER BY bulan, produk;
window-di-where-error.sql
Unduh berkas-- window function tidak boleh dipakai di WHERE: error yang diharapkan
SELECT bulan, produk, jumlah
FROM penjualan_bulanan
WHERE sum(jumlah) OVER (PARTITION BY produk) > 500
ORDER BY bulan, produk;