TL;DR
Program latihan SQL 30 hari ini membagi 30 soal jadi empat tingkat: minggu pertama filter dan agregasi, minggu kedua JOIN dan tanggal, minggu ketiga subquery dan CTE, minggu keempat window function. Tiap soal dikerjain 20 sampai 30 menit dengan satu skema tabel toko yang sama, jadi kamu nggak buang waktu bolak-balik memahami data baru.
Latihan SQL 30 hari ini isinya satu soal per hari yang naik tingkat, dari SELECT dasar sampai window function. Semua soal pakai satu skema tabel toko yang sama, jadi kamu nggak buang waktu ngerti data baru tiap hari.
Alasan formatnya harian sederhana. SQL itu keterampilan otot, bukan bahan hafalan. Ngerjain 30 soal dalam satu akhir pekan bikin kamu ngerti, tapi nggak bikin kamu inget.
Ambil satu soal, kerjain 20 sampai 30 menit, baru buka pembahasannya.
Empat tabel, mirip struktur toko online kecil di Indonesia.
pelanggan(id, nama, kota, tanggal_daftar)
produk(id, nama, kategori, harga)
pesanan(id, pelanggan_id, tanggal, total, status)
pesanan_item(pesanan_id, produk_id, qty, harga)
Kolom status isinya selesai, batal, atau pending. Hampir semua soal cuma ngitung yang selesai, dan itu memang sengaja biar kamu terbiasa nanya definisi angka sebelum ngetik.
Semua contoh ditulis dengan sintaks PostgreSQL. Rujukan fungsi lengkapnya ada di dokumentasi resmi PostgreSQL.
| Hari | Soal | Yang dilatih |
|---|---|---|
| 1 | Tampilkan nama dan kota semua pelanggan dari Surabaya | SELECT, WHERE |
| 2 | Ambil pesanan bulan Maret 2025 yang statusnya selesai | Filter tanggal |
| 3 | Sepuluh pesanan dengan nilai terbesar | ORDER BY, LIMIT |
| 4 | Daftar kota unik tempat pelanggan tinggal | DISTINCT |
| 5 | Jumlah pesanan, total omzet, dan rata-rata nilai pesanan | COUNT, SUM, AVG |
| 6 | Omzet per kategori produk | GROUP BY plus JOIN |
| 7 | Kategori dengan omzet di atas 10 juta saja | HAVING |
Pembahasan hari 6:
SELECT pr.kategori,
SUM(pi.qty * pi.harga) AS omzet,
COUNT(DISTINCT pe.id) AS jumlah_pesanan
FROM pesanan_item pi
JOIN produk pr ON pr.id = pi.produk_id
JOIN pesanan pe ON pe.id = pi.pesanan_id
WHERE pe.status = 'selesai'
GROUP BY pr.kategori
ORDER BY omzet DESC;
Perhatiin COUNT(DISTINCT pe.id). Tanpa DISTINCT, satu pesanan berisi 4 barang kehitung 4 kali. Kesalahan ini muncul di hampir semua laporan pertama yang aku review.
Beda WHERE dan HAVING di hari 7: WHERE nyaring baris sebelum dikelompokkan, HAVING nyaring hasil setelah dikelompokkan. Rangkuman perilaku fungsi ringkasnya ada di glosarium aggregate function.
| Hari | Soal | Yang dilatih |
|---|---|---|
| 8 | Nama pelanggan beserta tanggal pesanannya | INNER JOIN |
| 9 | Pelanggan yang belum pernah pesan sama sekali | LEFT JOIN plus IS NULL |
| 10 | Nama pelanggan, nama produk, dan qty tiap baris pesanan | JOIN tiga tabel |
| 11 | Total belanja per pelanggan, urut dari terbesar | JOIN plus GROUP BY |
| 12 | Kelompokkan pesanan jadi kecil, sedang, besar | CASE WHEN |
| 13 | Ganti kota kosong jadi "tidak diisi" | COALESCE |
| 14 | Omzet per bulan sepanjang 2025 | DATE_TRUNC |
Pembahasan hari 9:
SELECT p.id, p.nama
FROM pelanggan p
LEFT JOIN pesanan o ON o.pelanggan_id = p.id
WHERE o.id IS NULL;
Ini soal yang paling sering bikin macet di minggu kedua. Kuncinya: LEFT JOIN nyimpan semua baris pelanggan, dan pelanggan tanpa pasangan dapat NULL di semua kolom pesanan. Baris NULL itu yang kamu saring.
Perbandingan biasa nggak jalan buat NULL. WHERE o.id != 5 bakal ngebuang baris NULL, bukan nyimpan.
Pembahasan hari 14:
SELECT DATE_TRUNC('month', tanggal) AS bulan,
SUM(total) AS omzet
FROM pesanan
WHERE status = 'selesai'
AND tanggal >= DATE '2025-01-01'
AND tanggal < DATE '2026-01-01'
GROUP BY 1
ORDER BY 1;
Batas kanan pakai tanda kurang dari, bukan BETWEEN. Kalau kolomnya bertipe timestamp, BETWEEN sampai 31 Desember bakal ngebuang transaksi jam 10 malam di hari terakhir.
| Hari | Soal | Yang dilatih |
|---|---|---|
| 15 | Pesanan yang nilainya di atas rata-rata | Subquery di WHERE |
| 16 | Tiap pelanggan beserta jumlah pesanannya | Subquery di SELECT |
| 17 | Tulis ulang soal hari 16 pakai CTE | WITH |
| 18 | Pelanggan yang pernah beli kategori Sembako | EXISTS |
| 19 | Gabungkan daftar kota pelanggan dan kota gudang | UNION vs UNION ALL |
| 20 | Pasangan pelanggan yang tinggal di kota sama | Self join |
| 21 | Produk terlaris di kota dengan omzet terbesar | CTE bertingkat |
Pembahasan hari 20:
SELECT a.nama AS pelanggan_1,
b.nama AS pelanggan_2,
a.kota
FROM pelanggan a
JOIN pelanggan b
ON a.kota = b.kota
AND a.id < b.id
ORDER BY a.kota;
Syarat a.id < b.id yang bikin querynya benar. Tanpa itu, tiap pasangan muncul dua kali dan tiap orang berpasangan sama dirinya sendiri.
Buat hari 19, ingat bedanya: UNION ngebuang baris kembar, UNION ALL nyimpan semuanya. UNION ALL lebih cepat karena nggak perlu ngecek duplikat.
| Hari | Soal | Yang dilatih |
|---|---|---|
| 22 | Nomor urut pesanan tiap pelanggan | ROW_NUMBER |
| 23 | Peringkat produk per kategori, tangani nilai seri | RANK vs DENSE_RANK |
| 24 | Pertumbuhan omzet dibanding bulan sebelumnya | LAG |
| 25 | Omzet kumulatif sepanjang tahun | SUM OVER |
| 26 | Bagi pelanggan jadi 4 kelompok belanja | NTILE |
| 27 | Rata-rata bergerak omzet 3 bulan | ROWS BETWEEN |
| 28 | Transaksi terakhir tiap pelanggan | ROW_NUMBER plus filter |
Pembahasan hari 24:
WITH omzet_bulanan AS (
SELECT DATE_TRUNC('month', tanggal) AS bulan,
SUM(total) AS omzet
FROM pesanan
WHERE status = 'selesai'
GROUP BY 1
)
SELECT bulan,
omzet,
LAG(omzet) OVER (ORDER BY bulan) AS omzet_bulan_lalu,
ROUND(
(omzet - LAG(omzet) OVER (ORDER BY bulan)) * 100.0
/ NULLIF(LAG(omzet) OVER (ORDER BY bulan), 0), 2
) AS pertumbuhan_persen
FROM omzet_bulanan
ORDER BY bulan;
NULLIF dipasang biar bulan dengan omzet nol nggak bikin pembagian error. Baris pertama pasti kosong pertumbuhannya, karena nggak ada bulan sebelumnya.
Pembahasan hari 27:
SELECT bulan,
omzet,
ROUND(AVG(omzet) OVER (
ORDER BY bulan
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 0) AS rata_3_bulan
FROM omzet_bulanan
ORDER BY bulan;
Bagian ROWS BETWEEN 2 PRECEDING AND CURRENT ROW nentuin jendela tiga baris. Tanpa itu, AVG OVER bakal ngerata-rata seluruh baris dan hasilnya sama di tiap bulan.
Angka rata-rata bergerak inilah yang biasanya dipasang di dashboard, soalnya dia meredam lonjakan hari besar. Cara nentuin ukuran yang layak dipantau ada di glosarium metrik.
Hari 29, retensi bulanan. Hitung berapa persen pelanggan yang belanja di bulan tertentu masih belanja lagi bulan berikutnya. Gabungan CTE, self join berbasis bulan, dan pembagian dengan NULLIF.
Hari 30, RFM sederhana. Buat tiap pelanggan, hitung tiga angka: jarak hari sejak transaksi terakhir, jumlah transaksi, dan total belanja. Lalu bagi tiap angka jadi 4 kelompok pakai NTILE, dan gabung jadi satu kode segmen.
WITH rfm AS (
SELECT pelanggan_id,
CURRENT_DATE - MAX(tanggal)::date AS recency,
COUNT(*) AS frequency,
SUM(total) AS monetary
FROM pesanan
WHERE status = 'selesai'
GROUP BY pelanggan_id
)
SELECT pelanggan_id,
NTILE(4) OVER (ORDER BY recency DESC) AS skor_r,
NTILE(4) OVER (ORDER BY frequency) AS skor_f,
NTILE(4) OVER (ORDER BY monetary) AS skor_m
FROM rfm;
Skor recency diurut menurun karena jarak hari makin kecil justru makin bagus.
Tiga titik macet yang paling sering aku lihat: hari 9, hari 19, dan hari 27. Ketiganya bukan soal sintaks rumit, melainkan soal ngebayangin hasil antara sebelum baris akhir keluar.
Patokannya 20 sampai 30 menit per soal di minggu pertama dan kedua, naik ke 40 menit di minggu keempat. Kalau satu soal makan lebih dari satu jam, berhenti dulu dan tulis ulang besok pagi.
Semua contoh ditulis dengan sintaks PostgreSQL. Sebagian besar jalan juga di MySQL 8 dan SQLite modern, kecuali DATE_TRUNC yang perlu diganti DATE_FORMAT di MySQL.
Urut lebih aman, soalnya soal minggu ketiga pakai hasil dari minggu kedua. Yang jangan diloncat itu minggu keempat, karena window function butuh kebiasaan mikir per kelompok baris.
Hari 9 soal LEFT JOIN dengan IS NULL, hari 19 soal beda UNION dan UNION ALL, dan hari 27 soal frame ROWS BETWEEN.
Cukup buat bagian teknisnya. Yang belum ketutup adalah kemampuan ngobrol: mengklarifikasi soal, menjelaskan logika sambil ngetik, dan menyadari kasus tepi.
Tiga puluh soal ini nutup hampir semua yang ditanyakan di tes SQL level pemula sampai menengah. Yang bikin beda bukan jumlah soalnya, tapi kebiasaan ngerjain satu soal tiap hari tanpa bolong.
Mulai hari ini juga dari soal nomor 1, jangan nunggu Senin.
Habis kelar hari ke-30, uji hasilnya lewat transkrip mock interview SQL dan nilai diri kamu pakai rubrik di sana. Kalau kamu butuh data mentah buat latihan sendiri, cara nyiapinnya ada di struk kasir jadi data.
Mau praktek langsung? Mulai latihan SQL gratis
Latihan interaktif, langsung di browser.
Kolom waktu isinya angka gede kayak 1704067200 dan bikin bingung? Itu Unix timestamp. Ini cara ngubahnya jadi tanggal beneran di SQL, plus balik lagi.
Data transaksi tersimpan UTC, tapi laporan harus jam WIB. Kalau salah konversi, angka penjualan tengah malam bisa kecatat di tanggal yang salah. Ini cara handle timezone di SQL dengan benar.
Nambah 30 hari ke tanggal invoice, ngurangin sebulan buat cari periode lalu, ngitung selisih hari antar order. Semua itu aritmetika tanggal, dan SQL punya operator INTERVAL buat ngerjainnya.