Laporan keuangan jarang berhenti pada angka satu periode. Manajer Toko Maju Jaya ingin melihat pendapatan setiap bulan dan berapa jumlahnya sejak awal tahun pada saat yang sama: kolom pendapatan Januari berdiri di samping kolom akumulasi Januari–bulan berjalan. Dengan GROUP BY, angka kumulatif seperti itu harus dihitung dengan subquery atau dijumlahkan ulang di aplikasi; dengan window function, cukup satu agregat sum yang diberi klausa OVER berisi ORDER BY. Chapter ini membahas pola running total — akumulasi berjalan yang bertambah setiap baris — mulai dari satu rangkaian data, varian per partisi, hingga kombinasi dengan grand total dan penerapannya untuk memantau pencapaian target semester.

Tabel Latihan #

Kita lanjut memakai domain penjualan bulanan Toko Maju Jaya: tabel penjualan_bulanan berisi pendapatan tiga produk sepanjang semester I 2026. Kolom bulan menyimpan tanggal pertama tiap bulan, dan satu bulan bisa memuat lebih dari satu baris karena setiap produk punya barisnya sendiri:

sql buat-tabel.sql
-- tabel latihan: pendapatan 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,
    pendapatan  numeric(12,2) NOT NULL
);

INSERT INTO penjualan_bulanan (id, produk, bulan, pendapatan) VALUES
    ( 1, 'Gula Pasir', DATE '2026-01-01',  3200000.00),
    ( 2, 'Kopi Gayo',  DATE '2026-01-01',  9200000.00),
    ( 3, 'Teh Melati', DATE '2026-01-01',  4100000.00),
    ( 4, 'Gula Pasir', DATE '2026-02-01',  3600000.00),
    ( 5, 'Kopi Gayo',  DATE '2026-02-01',  9800000.00),
    ( 6, 'Teh Melati', DATE '2026-02-01',  4300000.00),
    ( 7, 'Gula Pasir', DATE '2026-03-01',  3100000.00),
    ( 8, 'Kopi Gayo',  DATE '2026-03-01',  8900000.00),
    ( 9, 'Teh Melati', DATE '2026-03-01',  4600000.00),
    (10, 'Gula Pasir', DATE '2026-04-01',  3800000.00),
    (11, 'Kopi Gayo',  DATE '2026-04-01', 10200000.00),
    (12, 'Teh Melati', DATE '2026-04-01',  4400000.00),
    (13, 'Gula Pasir', DATE '2026-05-01',  3500000.00),
    (14, 'Kopi Gayo',  DATE '2026-05-01', 10800000.00),
    (15, 'Teh Melati', DATE '2026-05-01',  4800000.00),
    (16, 'Gula Pasir', DATE '2026-06-01',  3900000.00),
    (17, 'Kopi Gayo',  DATE '2026-06-01', 11500000.00),
    (18, 'Teh Melati', DATE '2026-06-01',  5100000.00);

SELECT id, produk, bulan, pendapatan
FROM penjualan_bulanan
ORDER BY bulan, produk;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 18
 id |   produk   |   bulan    | pendapatan  
----+------------+------------+-------------
  1 | Gula Pasir | 2026-01-01 |  3200000.00
  2 | Kopi Gayo  | 2026-01-01 |  9200000.00
  3 | Teh Melati | 2026-01-01 |  4100000.00
  4 | Gula Pasir | 2026-02-01 |  3600000.00
  5 | Kopi Gayo  | 2026-02-01 |  9800000.00
  6 | Teh Melati | 2026-02-01 |  4300000.00
  7 | Gula Pasir | 2026-03-01 |  3100000.00
  8 | Kopi Gayo  | 2026-03-01 |  8900000.00
  9 | Teh Melati | 2026-03-01 |  4600000.00
 10 | Gula Pasir | 2026-04-01 |  3800000.00
 11 | Kopi Gayo  | 2026-04-01 | 10200000.00
 12 | Teh Melati | 2026-04-01 |  4400000.00
 13 | Gula Pasir | 2026-05-01 |  3500000.00
 14 | Kopi Gayo  | 2026-05-01 | 10800000.00
 15 | Teh Melati | 2026-05-01 |  4800000.00
 16 | Gula Pasir | 2026-06-01 |  3900000.00
 17 | Kopi Gayo  | 2026-06-01 | 11500000.00
 18 | Teh Melati | 2026-06-01 |  5100000.00
(18 rows)

Running Total pada Satu Rangkaian #

Bentuk paling sederhana dari running total muncul pada satu rangkaian data yang sudah terurut waktu. Query berikut menyaring pendapatan Kopi Gayo saja, lalu menghitung akumulasi berjalan dengan sum(pendapatan) OVER (ORDER BY bulan) — plus count(*) dengan jendela yang sama sebagai penghitung urutan bulan:

-- running total pada satu rangkaian: pendapatan Kopi Gayo Januari-Juni

SELECT bulan,
       pendapatan,
       sum(pendapatan) OVER (ORDER BY bulan) AS total_berjalan,
       count(*) OVER (ORDER BY bulan)        AS bulan_ke
FROM penjualan_bulanan
WHERE produk = 'Kopi Gayo'
ORDER BY bulan;

Output yang Dihasilkan:

text
   bulan    | pendapatan  | total_berjalan | bulan_ke 
------------+-------------+----------------+----------
 2026-01-01 |  9200000.00 |     9200000.00 |        1
 2026-02-01 |  9800000.00 |    19000000.00 |        2
 2026-03-01 |  8900000.00 |    27900000.00 |        3
 2026-04-01 | 10200000.00 |    38100000.00 |        4
 2026-05-01 | 10800000.00 |    48900000.00 |        5
 2026-06-01 | 11500000.00 |    60400000.00 |        6
(6 rows)

Mekanismenya bekerja seperti ini: klausa OVER (ORDER BY bulan) memerintahkan PostgreSQL mengurutkan baris-baris jendela berdasarkan bulan, lalu untuk setiap baris menghitung sum atas baris-baris tersebut mulai dari paling awal sampai baris yang sedang diproses. Baris Februari menjumlahkan Januari dan Februari (19.000.000); baris Maret menjumlahkan Januari sampai Maret (27.900.000); dan seterusnya sampai baris Juni yang totalnya 60.400.000 rupiah. Perhatikan dua hal penting: kolom pendapatan asli tetap utuh di samping hasil akumulasinya, dan count(*) OVER (ORDER BY bulan) menghasilkan pola yang sama — 1, 2, 3, … — sehingga kolom itu berfungsi sebagai nomor urut bulan sekaligus bukti bahwa count juga mengikuti aturan akumulasi berjalan.

Mari kita bedah query tersebut baris per baris:

  1. Baris 3 (sum(pendapatan) OVER (ORDER BY bulan)) — agregat sum dipakai sebagai window function; ORDER BY bulan di dalam OVER membuat frame penjumlahan tumbuh mengikuti urutan waktu, menghasilkan akumulasi berjalan.
  2. Baris 4 (count(*) OVER (ORDER BY bulan)) — pola yang sama dengan agregat berbeda: penghitung baris yang ikut berjalan dari 1 sampai 6.
  3. Baris 6 (WHERE produk = 'Kopi Gayo') — penyaringan dilakukan sebelum window function bekerja, sehingga jendela hanya berisi enam baris Kopi Gayo.
  4. Baris 7 (ORDER BY bulan) — ORDER BY query luar mengatur urutan tampilan hasil; urutan ini terpisah dari urutan di dalam OVER, meskipun keduanya kebetulan sama.

Running Total per Produk dengan PARTITION BY #

Laporan nyata jarang berisi satu produk saja. Tanpa PARTITION BY, sum(...) OVER (ORDER BY bulan) pada tabel penuh akan mencampur pendapatan semua produk dalam satu akumulasi. Menambahkan PARTITION BY produk memisahkan rangkaian tiap produk: akumulasi dimulai dari nol setiap kali window function pindah ke partisi produk berikutnya:

-- running total per produk: PARTITION BY mereset akumulasi di tiap partisi

SELECT produk,
       bulan,
       pendapatan,
       sum(pendapatan) OVER (PARTITION BY produk ORDER BY bulan) AS total_berjalan,
       count(*) OVER (PARTITION BY produk ORDER BY bulan)        AS bulan_ke
FROM penjualan_bulanan
ORDER BY produk, bulan;

Output yang Dihasilkan:

text
   produk   |   bulan    | pendapatan  | total_berjalan | bulan_ke 
------------+------------+-------------+----------------+----------
 Gula Pasir | 2026-01-01 |  3200000.00 |     3200000.00 |        1
 Gula Pasir | 2026-02-01 |  3600000.00 |     6800000.00 |        2
 Gula Pasir | 2026-03-01 |  3100000.00 |     9900000.00 |        3
 Gula Pasir | 2026-04-01 |  3800000.00 |    13700000.00 |        4
 Gula Pasir | 2026-05-01 |  3500000.00 |    17200000.00 |        5
 Gula Pasir | 2026-06-01 |  3900000.00 |    21100000.00 |        6
 Kopi Gayo  | 2026-01-01 |  9200000.00 |     9200000.00 |        1
 Kopi Gayo  | 2026-02-01 |  9800000.00 |    19000000.00 |        2
 Kopi Gayo  | 2026-03-01 |  8900000.00 |    27900000.00 |        3
 Kopi Gayo  | 2026-04-01 | 10200000.00 |    38100000.00 |        4
 Kopi Gayo  | 2026-05-01 | 10800000.00 |    48900000.00 |        5
 Kopi Gayo  | 2026-06-01 | 11500000.00 |    60400000.00 |        6
 Teh Melati | 2026-01-01 |  4100000.00 |     4100000.00 |        1
 Teh Melati | 2026-02-01 |  4300000.00 |     8400000.00 |        2
 Teh Melati | 2026-03-01 |  4600000.00 |    13000000.00 |        3
 Teh Melati | 2026-04-01 |  4400000.00 |    17400000.00 |        4
 Teh Melati | 2026-05-01 |  4800000.00 |    22200000.00 |        5
 Teh Melati | 2026-06-01 |  5100000.00 |    27300000.00 |        6
(18 rows)

Baca hasilnya per blok produk. Untuk Gula Pasir, kolom total_berjalan naik dari 3.200.000, 6.800.000, sampai 21.100.000 rupiah di bulan Juni; begitu blok berganti ke Kopi Gayo, akumulasi reset dan mulai lagi dari 9.200.000. Kolom bulan_ke juga kembali ke 1 di setiap awal partisi — angka 1 sampai 6 berulang tiga kali. Inilah kombinasi yang paling sering dipakai untuk running total: PARTITION BY menentukan rangkaian mana yang diakumulasi, ORDER BY menentukan arah waktu akumulasinya.

Running Total dan Grand Total dalam Satu Query #

Pertanyaan berikutnya yang biasanya menyertai running total: berapa persen kontribusi tiap bulan terhadap keseluruhan semester? Jawabannya membutuhkan dua angka sekaligus — total berjalan dan grand total. Kedua-duanya bisa dihitung dalam satu query dengan dua spesifikasi jendela yang berbeda: OVER (ORDER BY bulan) untuk akumulasi, dan OVER () untuk total seluruh partisi:

sql grand-total.sql
-- total bulanan, running total, dan grand total dalam satu query

WITH bulanan AS (
    SELECT bulan,
           sum(pendapatan) AS total_bulan
    FROM penjualan_bulanan
    GROUP BY bulan
)
SELECT bulan,
       total_bulan,
       sum(total_bulan) OVER (ORDER BY bulan) AS total_berjalan,
       sum(total_bulan) OVER ()               AS total_keseluruhan,
       round(total_bulan / sum(total_bulan) OVER () * 100, 1) AS persen_dari_total
FROM bulanan
ORDER BY bulan;

Output yang Dihasilkan:

text
   bulan    | total_bulan | total_berjalan | total_keseluruhan | persen_dari_total 
------------+-------------+----------------+-------------------+-------------------
 2026-01-01 | 16500000.00 |    16500000.00 |      108800000.00 |              15.2
 2026-02-01 | 17700000.00 |    34200000.00 |      108800000.00 |              16.3
 2026-03-01 | 16600000.00 |    50800000.00 |      108800000.00 |              15.3
 2026-04-01 | 18400000.00 |    69200000.00 |      108800000.00 |              16.9
 2026-05-01 | 19100000.00 |    88300000.00 |      108800000.00 |              17.6
 2026-06-01 | 20500000.00 |   108800000.00 |      108800000.00 |              18.8
(6 rows)

Tiga teknik bekerja bersama di query ini:

  1. CTE bulanan — data mentah berisi tiga baris per bulan, jadi total bulanan dihitung dulu dengan GROUP BY bulan menghasilkan enam baris ringkasan; window function kemudian bekerja di atas ringkasan itu.
  2. sum(total_bulan) OVER (ORDER BY bulan) — running total di tingkat bulanan: 16.500.000, 34.200.000, …, 108.800.000.
  3. sum(total_bulan) OVER () — jendela tanpa ORDER BY dan tanpa PARTITION BY mencakup seluruh baris hasil, sehingga setiap baris menerima grand total 108.800.000. Grand total lalu dipakai sebagai penyebut persen_dari_total — kolom yang menunjukkan kontribusi tiap bulan, dari 15,2% di Januari hingga 18,8% di Juni.

Perhatikan pola pentingnya: satu kolom grand total yang sama bisa dipakai berulang kali dalam daftar SELECT tanpa menghitung ulang subquery. OVER () adalah cara paling ringkas menempelkan angka keseluruhan pada setiap baris.

Use Case: Progres Terhadap Target Semester #

Running total menjadi sangat berguna begitu dibandingkan dengan angka patokan. Misalkan manajemen menetapkan target pendapatan semester I sebesar Rp105.000.000. Dengan membagi total berjalan terhadap target tersebut, setiap baris bulanan langsung menunjukkan persentase pencapaian dan statusnya:

-- use case: progres pencapaian target pendapatan semester (Rp105.000.000)

WITH bulanan AS (
    SELECT bulan,
           sum(pendapatan) AS total_bulan
    FROM penjualan_bulanan
    GROUP BY bulan
)
SELECT bulan,
       total_bulan,
       sum(total_bulan) OVER (ORDER BY bulan) AS total_berjalan,
       round(sum(total_bulan) OVER (ORDER BY bulan) / 105000000 * 100, 1) AS progres_target_pct,
       CASE WHEN sum(total_bulan) OVER (ORDER BY bulan) >= 105000000
            THEN 'target tercapai'
            ELSE 'belum tercapai'
       END AS status_target
FROM bulanan
ORDER BY bulan;

Output yang Dihasilkan:

text
   bulan    | total_bulan | total_berjalan | progres_target_pct |  status_target  
------------+-------------+----------------+--------------------+-----------------
 2026-01-01 | 16500000.00 |    16500000.00 |               15.7 | belum tercapai
 2026-02-01 | 17700000.00 |    34200000.00 |               32.6 | belum tercapai
 2026-03-01 | 16600000.00 |    50800000.00 |               48.4 | belum tercapai
 2026-04-01 | 18400000.00 |    69200000.00 |               65.9 | belum tercapai
 2026-05-01 | 19100000.00 |    88300000.00 |               84.1 | belum tercapai
 2026-06-01 | 20500000.00 |   108800000.00 |              103.6 | target tercapai
(6 rows)

Hasilnya membentuk kurva progres yang naik: 15,7% pada Januari, 48,4% pada Maret, 84,1% pada Mei — sampai Juni menutup semester di angka 103,6% dengan status target tercapai. Pola yang sama berlaku untuk kasus lain yang bentuknya serupa: saldo berjalan pada mutasi rekening dihitung dengan menjumlahkan setoran dan penarikan berurutan tanggal, dan progres produksi dihitung dengan mengakumulasi unit yang selesai. Semuanya satu resep: agregat berurutan sum(...) OVER (ORDER BY kolom_waktu) dibandingkan dengan patokan di sisi kanannya.

Sebagai rangkuman, varian running total yang sudah kita bahas:

KebutuhanPola jendela
Akumulasi satu rangkaiansum(x) OVER (ORDER BY waktu)
Akumulasi per kelompok, reset tiap kelompoksum(x) OVER (PARTITION BY kelompok ORDER BY waktu)
Grand total di setiap barissum(x) OVER ()
Persentase progressum(x) OVER (ORDER BY waktu) / patokan * 100

Running total menghitung akumulasi penuh sejak awal rangkaian. Banyak analisis data penjualan justru butuh versi yang lebih halus: rata-rata atas jendela waktu pendek yang ikut bergeser — misalnya rata-rata tiga bulan terakhir — untuk membaca tren tanpa terpengaruh lonjakan satu bulan. Chapter berikutnya, Moving Average , menghadirkan alat itu lewat klausa ROWS BETWEEN n PRECEDING AND CURRENT ROW.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- tabel latihan: pendapatan 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,
    pendapatan  numeric(12,2) NOT NULL
);

INSERT INTO penjualan_bulanan (id, produk, bulan, pendapatan) VALUES
    ( 1, 'Gula Pasir', DATE '2026-01-01',  3200000.00),
    ( 2, 'Kopi Gayo',  DATE '2026-01-01',  9200000.00),
    ( 3, 'Teh Melati', DATE '2026-01-01',  4100000.00),
    ( 4, 'Gula Pasir', DATE '2026-02-01',  3600000.00),
    ( 5, 'Kopi Gayo',  DATE '2026-02-01',  9800000.00),
    ( 6, 'Teh Melati', DATE '2026-02-01',  4300000.00),
    ( 7, 'Gula Pasir', DATE '2026-03-01',  3100000.00),
    ( 8, 'Kopi Gayo',  DATE '2026-03-01',  8900000.00),
    ( 9, 'Teh Melati', DATE '2026-03-01',  4600000.00),
    (10, 'Gula Pasir', DATE '2026-04-01',  3800000.00),
    (11, 'Kopi Gayo',  DATE '2026-04-01', 10200000.00),
    (12, 'Teh Melati', DATE '2026-04-01',  4400000.00),
    (13, 'Gula Pasir', DATE '2026-05-01',  3500000.00),
    (14, 'Kopi Gayo',  DATE '2026-05-01', 10800000.00),
    (15, 'Teh Melati', DATE '2026-05-01',  4800000.00),
    (16, 'Gula Pasir', DATE '2026-06-01',  3900000.00),
    (17, 'Kopi Gayo',  DATE '2026-06-01', 11500000.00),
    (18, 'Teh Melati', DATE '2026-06-01',  5100000.00);

SELECT id, produk, bulan, pendapatan
FROM penjualan_bulanan
ORDER BY bulan, produk;

grand-total.sql

Unduh berkas
sql
-- total bulanan, running total, dan grand total dalam satu query

WITH bulanan AS (
    SELECT bulan,
           sum(pendapatan) AS total_bulan
    FROM penjualan_bulanan
    GROUP BY bulan
)
SELECT bulan,
       total_bulan,
       sum(total_bulan) OVER (ORDER BY bulan) AS total_berjalan,
       sum(total_bulan) OVER ()               AS total_keseluruhan,
       round(total_bulan / sum(total_bulan) OVER () * 100, 1) AS persen_dari_total
FROM bulanan
ORDER BY bulan;

running-total-dasar.sql

Unduh berkas
sql
-- running total pada satu rangkaian: pendapatan Kopi Gayo Januari-Juni

SELECT bulan,
       pendapatan,
       sum(pendapatan) OVER (ORDER BY bulan) AS total_berjalan,
       count(*) OVER (ORDER BY bulan)        AS bulan_ke
FROM penjualan_bulanan
WHERE produk = 'Kopi Gayo'
ORDER BY bulan;

running-total-partisi.sql

Unduh berkas
sql
-- running total per produk: PARTITION BY mereset akumulasi di tiap partisi

SELECT produk,
       bulan,
       pendapatan,
       sum(pendapatan) OVER (PARTITION BY produk ORDER BY bulan) AS total_berjalan,
       count(*) OVER (PARTITION BY produk ORDER BY bulan)        AS bulan_ke
FROM penjualan_bulanan
ORDER BY produk, bulan;

use-case-target.sql

Unduh berkas
sql
-- use case: progres pencapaian target pendapatan semester (Rp105.000.000)

WITH bulanan AS (
    SELECT bulan,
           sum(pendapatan) AS total_bulan
    FROM penjualan_bulanan
    GROUP BY bulan
)
SELECT bulan,
       total_bulan,
       sum(total_bulan) OVER (ORDER BY bulan) AS total_berjalan,
       round(sum(total_bulan) OVER (ORDER BY bulan) / 105000000 * 100, 1) AS progres_target_pct,
       CASE WHEN sum(total_bulan) OVER (ORDER BY bulan) >= 105000000
            THEN 'target tercapai'
            ELSE 'belum tercapai'
       END AS status_target
FROM bulanan
ORDER BY bulan;