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:

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

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

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

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

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

  1. row_number() OVER (PARTITION BY produk ORDER BY jumlah DESC, bulan): setiap produk mendapat penomoran sendiri dari penjualan tertinggi ke terendah; bulan berperan sebagai tie-breaker sehingga April selalu mengungguli Mei ketika keduanya sama-sama 150 unit.
  2. Derived table t: membungkus hasil penomoran agar kolom rn bisa dirujuk — ingat pola dari chapter Konsep Window Function : window function tidak boleh disaring langsung di WHERE.
  3. WHERE rn <= 3: memilih nomor 1, 2, 3 dari setiap partisi — inilah “per kelompok” yang tidak mungkin dicapai LIMIT.
  4. 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:

sql deduplikasi.sql
-- 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:

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

Berkas Sumber

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

buat-tabel.sql

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