TL;DR
Moving average di SQL dihitung pakai window function AVG() OVER (ORDER BY tanggal ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), yang ngerata-ratain nilai hari ini plus 6 hari sebelumnya. Gunanya buat ngilangin noise harian (kayak lonjakan weekend) biar arah tren aslinya kelihatan. Jebakan terbesarnya: tanggal yang bolong, karena ROWS ngitung baris bukan hari, jadi window kamu bisa mundur lebih jauh dari yang kamu kira.
Moving average di SQL dihitung pakai window function AVG() OVER dengan frame yang mundur beberapa baris ke belakang. Rumus dasarnya:
AVG(total) OVER (
ORDER BY tanggal
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
Itu rata-rata hari ini plus 6 hari sebelumnya. Dikenal sebagai 7-day moving average.
Gunanya? Data penjualan harian itu berisik. Di dataset toko_berkah, penjualan Sabtu rata-rata Rp 2,01 juta sementara Senin cuma Rp 900 ribu, selisih 123%. Kalau kamu plot mentah-mentah, yang kelihatan cuma gigi gergaji mingguan, bukan arah bisnisnya.
Moving average buang siklus itu. Yang tersisa: tren aslinya.
Moving average adalah rata-rata yang dihitung ulang di tiap baris pakai jendela data yang geser. Baris tanggal 7 ngerata-ratain tanggal 1-7. Baris tanggal 8 ngerata-ratain tanggal 2-8. Jendelanya maju terus.
GROUP BY gak bisa ngelakuin ini. GROUP BY mampatkan banyak baris jadi satu. Kamu dapet satu angka per minggu, bukan angka bergeser per hari.
Window function beda: dia hitung agregat tapi tetep balikin semua baris. Itu bedanya.
| GROUP BY | Window function | |
|---|---|---|
| Jumlah baris hasil | Menyusut jadi 1 per grup | Sama kayak input |
| Bisa lihat nilai asli? | Nggak | Bisa, di samping hasil agregatnya |
| Cocok buat | Total per bulan | Moving average, running total, ranking |
Kalau konsep window function masih baru buat kamu, cek dulu definisi window function sebelum lanjut.
Tabel penjualan_harian di dataset toko_berkah punya 3 kolom: tanggal, kota, total.
SELECT tanggal,
total,
ROUND(AVG(total) OVER (
ORDER BY tanggal
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 0) AS ma_7hari
FROM penjualan_harian
ORDER BY tanggal;
Bedah tiap bagiannya:
AVG(total): fungsi agregatnya. Bisa diganti SUM, MAX, COUNT.OVER (...): yang bikin ini jadi window function, bukan agregat biasa.ORDER BY tanggal: urutan di dalam window. Wajib, karena "6 hari sebelumnya" gak punya arti tanpa urutan.ROWS BETWEEN 6 PRECEDING AND CURRENT ROW: framenya. 6 baris ke belakang plus baris ini = 7 baris total.Hasilnya di data toko_berkah:
tanggal total ma_7hari
2026-03-01 1850000 1850000 <- window baru 1 baris
2026-03-02 920000 1385000 <- window 2 baris
2026-03-03 1010000 1260000
2026-03-04 1140000 1230000
2026-03-05 1220000 1228000
2026-03-06 1580000 1286667
2026-03-07 1970000 1384286 <- window penuh 7 baris
2026-03-08 1910000 1392857
2026-03-09 880000 1387143
2026-03-10 1080000 1382857
Lihat kolom total: naik-turun brutal antara 880 ribu sampai 1,97 juta. Kolom ma_7hari: adem di kisaran 1,38-1,39 juta.
Itu yang kamu mau. Nomor yang bisa dijawab: "bisnis kita lagi naik atau turun?" Tanpa keganggu hari Sabtu.
Di baris pertama, window-nya cuma punya 1 nilai. Rata-rata dari 1 angka ya angka itu sendiri. Baris kedua punya 2 nilai, dan seterusnya sampai baris ke-7 baru penuh.
Ini normal, dan biasanya dibiarin. Tapi kalau kamu mau angkanya cuma muncul waktu window udah penuh:
SELECT tanggal,
total,
CASE
WHEN ROW_NUMBER() OVER (ORDER BY tanggal) < 7 THEN NULL
ELSE ROUND(AVG(total) OVER (
ORDER BY tanggal
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 0)
END AS ma_7hari
FROM penjualan_harian
ORDER BY tanggal;
Enam baris pertama jadi NULL. Chart kamu bakal mulai gambar garis MA dari hari ke-7. Lebih jujur, karena kamu gak bilang "rata-rata 7 hari" untuk angka yang cuma pakai 2 hari.
Ini yang bikin banyak moving average diam-diam salah.
ROWS BETWEEN 6 PRECEDING ngitung baris, bukan hari. Kalau toko kamu tutup tanggal 3, 4, dan 5, gak ada baris buat tanggal itu di tabel.
Baris tanggal 8 bakal mundur 6 baris, dan mendarat di tanggal 28 Februari. Window kamu sekarang nyaring 10 hari kalender, bukan 7. Query-nya gak error. Angkanya cuma salah.
Ada 2 solusi.
SELECT tanggal,
total,
ROUND(AVG(total) OVER (
ORDER BY tanggal
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
), 0) AS ma_7hari_aman
FROM penjualan_harian
ORDER BY tanggal;
RANGE ngitung berdasarkan nilai tanggalnya. Mau ada baris atau nggak, dia cuma ambil yang tanggalnya dalam 6 hari terakhir.
Didukung PostgreSQL, BigQuery, Snowflake, dan SQL Server 2022 ke atas. MySQL masih terbatas.
WITH kalender AS (
SELECT generate_series('2026-03-01'::date,
'2026-03-14'::date,
'1 day')::date AS tanggal
),
lengkap AS (
SELECT k.tanggal,
COALESCE(p.total, 0) AS total
FROM kalender k
LEFT JOIN penjualan_harian p ON p.tanggal = k.tanggal
)
SELECT tanggal,
total,
ROUND(AVG(total) OVER (
ORDER BY tanggal
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 0) AS ma_7hari
FROM lengkap
ORDER BY tanggal;
Bikin tabel kalender lengkap, LEFT JOIN data kamu ke situ, isi yang kosong pakai COALESCE jadi 0. Sekarang ROWS aman dipakai.
Cara ini lebih ribet tapi jalan di semua database. Dan bonus: kamu jadi bisa lihat hari mana aja yang datanya hilang.
Data toko_berkah Maret 2026, 31 hari. Kalau dilihat mentah, penjualan kelihatan datar-datar aja, naik-turun terus, gak jelas arahnya.
Aku hitung dua moving average sekaligus: 7 hari dan 14 hari.
SELECT tanggal,
total,
ROUND(AVG(total) OVER (ORDER BY tanggal
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 0) AS ma_7,
ROUND(AVG(total) OVER (ORDER BY tanggal
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW), 0) AS ma_14
FROM penjualan_harian
ORDER BY tanggal;
Hasilnya: MA-7 di awal Maret 1,38 juta, di akhir Maret 1,71 juta. Naik 24% dalam sebulan.
Angka itu gak kelihatan sama sekali di data mentah, karena ada 2 hari promo flash sale yang penjualannya 3x lipat. Dua hari itu bikin mata orang fokus ke spike, bukan ke garis dasarnya yang pelan-pelan naik.
Yang lebih menarik: MA-7 nyilang ke atas MA-14 di tanggal 18. Itu sinyal momentum: rata-rata jangka pendek udah lebih tinggi dari jangka menengah. Di analisis time series, crossover kayak gini sering dipakai buat nandain titik balik tren.
Kalau tabel kamu punya banyak kota, moving average tanpa PARTITION BY bakal nyampur.
Baris pertama Surabaya bakal ngambil 6 baris sebelumnya, yang isinya data Bandung. Angka Surabaya jadi kontaminasi.
SELECT tanggal,
kota,
total,
ROUND(AVG(total) OVER (
PARTITION BY kota
ORDER BY tanggal
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 0) AS ma_7hari_kota
FROM penjualan_harian
ORDER BY kota, tanggal;
PARTITION BY kota bikin window-nya reset tiap ganti kota. Bandung ngerata-ratain Bandung doang.
Tabel kamu 50.000 baris transaksi, bukan 1 baris per hari? Agregasi dulu.
WITH harian AS (
SELECT tanggal, SUM(total) AS total_harian
FROM transaksi
GROUP BY tanggal
)
SELECT tanggal,
total_harian,
ROUND(AVG(total_harian) OVER (
ORDER BY tanggal
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 0) AS ma_7hari
FROM harian
ORDER BY tanggal;
GROUP BY jalan duluan di CTE, window function jalan di query luar. Urutan ini penting. Kalau kamu paksa gabung dalam satu SELECT tanpa CTE, kebanyakan database bakal nolak.
Tanpa ORDER BY, gak ada urutan, jadi "6 baris sebelumnya" gak punya makna. Beberapa database kasih error, beberapa diam-diam ngasih rata-rata seluruh tabel di tiap baris.
ORDER BY tanggal di akhir query itu buat urutan tampilan. Yang di dalam OVER() buat urutan window. Dua-duanya beda, dan dua-duanya kamu butuh.
7-day MA butuh 6 PRECEDING, bukan 7. Baris saat ini juga ikut kehitung. Kalau kamu tulis 7 PRECEDING, itu 8-day MA.
Kalau data kamu udah halus, moving average cuma bikin tren telat kelihatan. Setiap moving average punya lag: MA-30 baru nunjukin perubahan setelah 2 mingguan.
Detail sintaks window frame per database bisa kamu cek di dokumentasi window function PostgreSQL.
ROWS ngitung baris, RANGE ngitung nilai tanggal. Kalau data harian kamu lengkap, hasilnya sama. Kalau ada tanggal bolong, ROWS bakal mundur lebih jauh dari 7 hari dan hasilnya salah.
Window-nya belum penuh. Baris pertama cuma punya 1 nilai buat dirata-ratain. Kalau mau disembunyiin, pakai CASE WHEN ROW_NUMBER() < 7 THEN NULL.
7 hari buat ngilangin efek weekend. 30 hari buat arah jangka menengah. Patokan: window harus kelipatan dari siklus yang mau kamu buang.
Bisa, tapi GROUP BY harus di CTE dulu, window function di query luar. Digabung dalam satu SELECT bakal ditolak kebanyakan database.
Tambahin PARTITION BY kota di dalam OVER-nya. Tanpa itu, baris pertama Surabaya bakal ngambil data Bandung.
Yang perlu diinget:
AVG() OVER (ORDER BY tanggal ROWS BETWEEN n-1 PRECEDING AND CURRENT ROW). Untuk MA-7, n-1 = 6.Sebelum masuk ke window function, pastiin agregasi dasarnya udah lancar. Kalau AVG dan SUM masih sering bikin kamu ragu soal NULL, benerin itu dulu.
Setelah moving average jadi, biasanya langkah berikutnya bikin chart-nya. Aku bahas cara nampilinnya di Cara Membuat Line Chart Excel.
Ambil satu tabel penjualan harian kamu, jalanin query MA-7 di atas, terus bandingin sama angka mentahnya. Kalau selisih hari tersibuk sama tersepi lebih dari 50%, moving average bakal ngubah cara kamu baca data itu.
Mau praktek langsung? Mulai latihan SQL gratis
Latihan interaktif, langsung di browser.
Analisa per kuartal, hari kerja, atau musim jadi ribet kalau tiap query ngitung ulang atribut tanggal. Tabel kalender nyimpen semua atribut itu sekali, biar tinggal di-JOIN.
Laporan penjualan harian sering bolong di hari tanpa transaksi. Ini cara bikin deret tanggal lengkap di SQL biar tiap hari muncul, walau nilainya nol.
Struktur organisasi tersimpan sebagai kolom id_atasan yang saling nunjuk. Ini cara narik rantai jabatan, hitung total bawahan, dan span of control cuma pakai SQL.