Banyak pertanyaan analitik sebenarnya pertanyaan penomoran: bulan ke berapa penjualan ini untuk produknya? Tiga bulan terbaik tiap produk yang mana? Bagaimana membuang baris duplikat hasil impor data yang disimpan dua kali? Semuanya dijawab satu fungsi: ROW_NUMBER(). Fungsi ini tidak menghitung apa pun dari nilai kolom — tugasnya sederhana namun ampuh, memberi nomor urut 1, 2, 3 kepada baris-baris di dalam sebuah jendela. Chapter ini membahas sintaks dasarnya, disiplin tie-breaker yang membuat hasilnya deterministik, lalu dua pola klasik yang paling sering dipakai di dunia nyata: top-N per kelompok dan deduplikasi.
Tabel Latihan #
Kita tetap memakai penjualan_bulanan — 16 baris penjualan bulanan per produk Toko Maju Jaya sepanjang semester I 2026 — dengan dua kekhasan yang sengaja dipertahankan: nilai seri pada jumlah (Kopi Gayo 150 unit di April dan Mei) untuk melatih disiplin tie-breaker, dan bulan yang hilang pada dua produk:
-- 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)Dasar ROW_NUMBER: Nomor Urut di Dalam Jendela #
ROW_NUMBER() menomori baris 1 sampai N sesuai urutan yang ditetapkan ORDER BY di dalam OVER. Dengan PARTITION BY, penomoran dimulai ulang dari 1 di tiap partisi. Perhatikan dua bentuk dasarnya:
-- ROW_NUMBER(): nomor urut baris di dalam jendela; tidak menerima argumen
-- (a) satu jendela global: nomori seluruh baris dari jumlah tertinggi
SELECT bulan,
produk,
jumlah,
row_number() OVER (ORDER BY jumlah DESC, id) AS nomor
FROM penjualan_bulanan
ORDER BY jumlah DESC, id;
-- (b) partisi per produk: nomor urut bulan untuk tiap produk
SELECT bulan,
produk,
jumlah,
row_number() OVER (PARTITION BY produk ORDER BY bulan) AS bulan_ke
FROM penjualan_bulanan
ORDER BY produk, bulan;Output yang Dihasilkan:
bulan | produk | jumlah | nomor
------------+------------+--------+-------
2026-06-01 | Kopi Gayo | 160 | 1
2026-04-01 | Kopi Gayo | 150 | 2
2026-05-01 | Kopi Gayo | 150 | 3
2026-02-01 | Kopi Gayo | 135 | 4
2026-06-01 | Teh Melati | 130 | 5
2026-03-01 | Kopi Gayo | 128 | 6
2026-01-01 | Kopi Gayo | 120 | 7
2026-04-01 | Teh Melati | 110 | 8
2026-02-01 | Teh Melati | 95 | 9
2026-01-01 | Teh Melati | 80 | 10
2026-03-01 | Teh Melati | 80 | 11
2026-06-01 | Gula Pasir | 75 | 12
2026-03-01 | Gula Pasir | 70 | 13
2026-04-01 | Gula Pasir | 70 | 14
2026-02-01 | Gula Pasir | 60 | 15
2026-05-01 | Gula Pasir | 55 | 16
(16 rows)
bulan | produk | jumlah | bulan_ke
------------+------------+--------+----------
2026-02-01 | Gula Pasir | 60 | 1
2026-03-01 | Gula Pasir | 70 | 2
2026-04-01 | Gula Pasir | 70 | 3
2026-05-01 | Gula Pasir | 55 | 4
2026-06-01 | Gula Pasir | 75 | 5
2026-01-01 | Kopi Gayo | 120 | 1
2026-02-01 | Kopi Gayo | 135 | 2
2026-03-01 | Kopi Gayo | 128 | 3
2026-04-01 | Kopi Gayo | 150 | 4
2026-05-01 | Kopi Gayo | 150 | 5
2026-06-01 | Kopi Gayo | 160 | 6
2026-01-01 | Teh Melati | 80 | 1
2026-02-01 | Teh Melati | 95 | 2
2026-03-01 | Teh Melati | 80 | 3
2026-04-01 | Teh Melati | 110 | 4
2026-06-01 | Teh Melati | 130 | 5
(16 rows)Query (a) menempatkan Juni milik Kopi Gayo (160 unit) di nomor 1 dan Mei milik Gula Pasir (55 unit) di nomor 16 — satu deret nomor untuk seluruh tabel. Query (b) membelah penomoran per produk: Kopi Gayo mendapat bulan_ke 1–6, sementara Teh Melati dan Gula Pasir hanya sampai 5 karena masing-masing kehilangan satu bulan. Bandingkan dengan count(*) OVER (PARTITION BY produk ORDER BY bulan) dari chapter OVER dan PARTITION BY
yang menghasilkan penomoran serupa — ROW_NUMBER() adalah cara yang jauh lebih eksplisit dan lazim untuk kebutuhan ini.
ROW_NUMBER Tidak Menerima Argumen #
Berbeda dari lag(jumlah) yang akan kita temui di chapter LAG dan LEAD
, ROW_NUMBER tidak membaca nilai kolom mana pun — sumber nomornya murni posisi baris. Karena itu tanda kurungnya memang dibiarkan kosong. Memberikan argumen justru ditolak PostgreSQL:
-- ROW_NUMBER tidak menerima argumen: error yang diharapkan
SELECT bulan, produk, row_number(jumlah) OVER (ORDER BY id) AS nomor
FROM penjualan_bulanan
ORDER BY bulan, produk;Output yang Dihasilkan:
psql:row-number-argumen-error.sql:4: ERROR: function row_number(integer) does not exist
LINE 1: SELECT bulan, produk, row_number(jumlah) OVER (ORDER BY id) ...
^
HINT: No function matches the given name and argument types. You might need to add explicit type casts.Pesan function row_number(integer) does not exist terlihat seperti mencari fungsi yang salah, tetapi maksudnya satu: tidak ada varian row_number yang menerima kolom. Jika yang Anda butuhkan adalah peringkat yang dipengaruhi nilai — misalnya baris dengan nilai sama mendapat nomor sama — yang dicari adalah RANK dan DENSE_RANK, bukan ROW_NUMBER.
Disiplin Tie-Breaker: Nomor yang Deterministik #
Perhatikan query (a) di atas sekali lagi: ORDER BY jumlah DESC, id. Kolom id di situ bukan hiasan. Dua baris Kopi Gayo sama-sama terjual 150 unit (April dan Mei); bila OVER hanya memuat ORDER BY jumlah DESC, PostgreSQL bebas menomori keduanya 2 dan 3 dalam urutan mana pun — hasilnya tidak dijamin sama antar eksekusi, dan laporan Anda bisa berubah tanpa sebab yang jelas. Dengan menambahkan id sebagai tie-breaker, urutan kedua baris seri itu terkunci: April (id 10) selalu nomor 2, Mei (id 12) selalu nomor 3.
Pola Top-N per Kelompok #
Inilah pola yang membuat ROW_NUMBER terkenal: “tiga bulan terbaik untuk setiap produk”. Satu query LIMIT tidak bisa menjawabnya karena LIMIT memotong hasil secara global, bukan per kelompok. Solusinya menomori dulu di query dalam, lalu menyaring nomornya di query luar:
-- pola klasik top-N per kelompok: ROW_NUMBER + derived table
SELECT produk, bulan, jumlah
FROM (
SELECT produk, bulan, jumlah,
row_number() OVER (PARTITION BY produk ORDER BY jumlah DESC, bulan) AS rn
FROM penjualan_bulanan
) AS t
WHERE rn <= 3
ORDER BY produk, rn;Output yang Dihasilkan:
produk | bulan | jumlah
------------+------------+--------
Gula Pasir | 2026-06-01 | 75
Gula Pasir | 2026-03-01 | 70
Gula Pasir | 2026-04-01 | 70
Kopi Gayo | 2026-06-01 | 160
Kopi Gayo | 2026-04-01 | 150
Kopi Gayo | 2026-05-01 | 150
Teh Melati | 2026-06-01 | 130
Teh Melati | 2026-04-01 | 110
Teh Melati | 2026-02-01 | 95
(9 rows)Mari kita bedah polanya langkah demi langkah:
row_number() OVER (PARTITION BY produk ORDER BY jumlah DESC, bulan): setiap produk mendapat penomoran sendiri dari penjualan tertinggi ke terendah;bulanberperan sebagai tie-breaker sehingga April selalu mengungguli Mei ketika keduanya sama-sama 150 unit.- Derived table
t: membungkus hasil penomoran agar kolomrnbisa dirujuk — ingat pola dari chapter Konsep Window Function : window function tidak boleh disaring langsung diWHERE. WHERE rn <= 3: memilih nomor 1, 2, 3 dari setiap partisi — inilah “per kelompok” yang tidak mungkin dicapaiLIMIT.ORDER BY produk, rn: hasil akhir dikelompokkan rapi per produk, tiga bulan terbaiknya berurutan.
Hasilnya tepat 9 baris — tiga per produk. Tapi perhatikan juga batas pola ini: Gula Pasir menampilkan Maret dan April yang sama-sama 70 unit berkat tie-breaker bulan, padahal keduanya “sama kuat”. Bila kebijakan Anda adalah semua baris yang seri di posisi ke-N ikut tampil, ganti ROW_NUMBER dengan RANK — persis topik chapter berikutnya.
Pola Deduplikasi: Pertahankan Satu Baris per Kelompok #
Pola kedua lahir dari masalah yang sangat praktis: data hasil impor atau sinkronisasi sistem lain sering menyimpan kejadian yang sama berkali-kali dengan primary key berbeda. Tabel catatan_stok berikut mencatat stok per produk per tanggal, dan tiga catatan tercatat ganda:
-- deduplikasi dengan ROW_NUMBER: simpan baris ber-id terkecil per kelompok
CREATE TABLE catatan_stok (
id integer PRIMARY KEY,
produk text NOT NULL,
tanggal date NOT NULL,
stok integer NOT NULL
);
INSERT INTO catatan_stok (id, produk, tanggal, stok) VALUES
(1, 'Kopi Gayo', DATE '2026-06-28', 40),
(2, 'Kopi Gayo', DATE '2026-06-28', 38),
(3, 'Teh Melati', DATE '2026-06-28', 25),
(4, 'Teh Melati', DATE '2026-06-29', 20),
(5, 'Teh Melati', DATE '2026-06-29', 22),
(6, 'Gula Pasir', DATE '2026-06-29', 30);
-- tandai duplikat: rn > 1 berarti baris kedua, ketiga, dst. untuk kombinasi sama
SELECT id, produk, tanggal, stok,
row_number() OVER (PARTITION BY produk, tanggal ORDER BY id) AS rn
FROM catatan_stok
ORDER BY id;
-- hapus semua baris duplikat (rn > 1) lewat derived table
DELETE FROM catatan_stok
WHERE id IN (
SELECT id
FROM (
SELECT id,
row_number() OVER (PARTITION BY produk, tanggal ORDER BY id) AS rn
FROM catatan_stok
) AS t
WHERE rn > 1
);
-- hasil akhir: tepat satu baris per kombinasi produk + tanggal
SELECT id, produk, tanggal, stok
FROM catatan_stok
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 6
id | produk | tanggal | stok | rn
----+------------+------------+------+----
1 | Kopi Gayo | 2026-06-28 | 40 | 1
2 | Kopi Gayo | 2026-06-28 | 38 | 2
3 | Teh Melati | 2026-06-28 | 25 | 1
4 | Teh Melati | 2026-06-29 | 20 | 1
5 | Teh Melati | 2026-06-29 | 22 | 2
6 | Gula Pasir | 2026-06-29 | 30 | 1
(6 rows)
DELETE 2
id | produk | tanggal | stok
----+------------+------------+------
1 | Kopi Gayo | 2026-06-28 | 40
3 | Teh Melati | 2026-06-28 | 25
4 | Teh Melati | 2026-06-29 | 20
6 | Gula Pasir | 2026-06-29 | 30
(4 rows)Query pertama memperlihatkan penandaannya: PARTITION BY produk, tanggal mengelompokkan baris-baris yang mencatat kejadian yang sama, dan ORDER BY id memastikan baris pertama masuk selalu mendapat rn = 1. Statement DELETE kemudian menghapus semua baris yang rn > 1 melalui subquery IN — baris dengan id 2 dan 5 lenyap, tag DELETE 2 menandainya. Tabel akhir menyisakan tepat empat baris, satu per kombinasi produk dan tanggal, dengan versi ber-id terkecil yang bertahan.
ROW_NUMBER menuntut satu hal yang kadang tidak kita inginkan: setiap baris harus menerima nomor berbeda, sekalipun nilainya seri. Untuk leaderboard yang jujur terhadap hasil seri — dua juara pertama, lalu peringkat ketiga yang terlompati — kita butuh fungsi yang menghormati kesamaan: RANK dan DENSE_RANK, bintang chapter berikutnya.
Referensi #
- Window Functions (SQL Reference)
— tabel fungsi window termasuk
ROW_NUMBER. - Tutorial: Window Functions — contoh resmi penomoran dan ranking dalam jendela.
- SELECT (SQL Reference)
— posisi klausa
OVERdalam query SELECT.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
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;
dasar-row-number.sql
Unduh berkas-- ROW_NUMBER(): nomor urut baris di dalam jendela; tidak menerima argumen
-- (a) satu jendela global: nomori seluruh baris dari jumlah tertinggi
SELECT bulan,
produk,
jumlah,
row_number() OVER (ORDER BY jumlah DESC, id) AS nomor
FROM penjualan_bulanan
ORDER BY jumlah DESC, id;
-- (b) partisi per produk: nomor urut bulan untuk tiap produk
SELECT bulan,
produk,
jumlah,
row_number() OVER (PARTITION BY produk ORDER BY bulan) AS bulan_ke
FROM penjualan_bulanan
ORDER BY produk, bulan;
deduplikasi.sql
Unduh berkas-- deduplikasi dengan ROW_NUMBER: simpan baris ber-id terkecil per kelompok
CREATE TABLE catatan_stok (
id integer PRIMARY KEY,
produk text NOT NULL,
tanggal date NOT NULL,
stok integer NOT NULL
);
INSERT INTO catatan_stok (id, produk, tanggal, stok) VALUES
(1, 'Kopi Gayo', DATE '2026-06-28', 40),
(2, 'Kopi Gayo', DATE '2026-06-28', 38),
(3, 'Teh Melati', DATE '2026-06-28', 25),
(4, 'Teh Melati', DATE '2026-06-29', 20),
(5, 'Teh Melati', DATE '2026-06-29', 22),
(6, 'Gula Pasir', DATE '2026-06-29', 30);
-- tandai duplikat: rn > 1 berarti baris kedua, ketiga, dst. untuk kombinasi sama
SELECT id, produk, tanggal, stok,
row_number() OVER (PARTITION BY produk, tanggal ORDER BY id) AS rn
FROM catatan_stok
ORDER BY id;
-- hapus semua baris duplikat (rn > 1) lewat derived table
DELETE FROM catatan_stok
WHERE id IN (
SELECT id
FROM (
SELECT id,
row_number() OVER (PARTITION BY produk, tanggal ORDER BY id) AS rn
FROM catatan_stok
) AS t
WHERE rn > 1
);
-- hasil akhir: tepat satu baris per kombinasi produk + tanggal
SELECT id, produk, tanggal, stok
FROM catatan_stok
ORDER BY id;
row-number-argumen-error.sql
Unduh berkas-- ROW_NUMBER tidak menerima argumen: error yang diharapkan
SELECT bulan, produk, row_number(jumlah) OVER (ORDER BY id) AS nomor
FROM penjualan_bulanan
ORDER BY bulan, produk;
top-n-per-group.sql
Unduh berkas-- pola klasik top-N per kelompok: ROW_NUMBER + derived table
SELECT produk, bulan, jumlah
FROM (
SELECT produk, bulan, jumlah,
row_number() OVER (PARTITION BY produk ORDER BY jumlah DESC, bulan) AS rn
FROM penjualan_bulanan
) AS t
WHERE rn <= 3
ORDER BY produk, rn;