Latihan SQL 30 Hari: 30 Soal Bertingkat dengan Pembahasan
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.
Skema tabel yang dipakai
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.
Minggu 1: filter dan agregasi (hari 1 sampai 7)
| 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.
Minggu 2: JOIN dan tanggal (hari 8 sampai 14)
| 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.
Minggu 3: subquery, CTE, dan gabungan (hari 15 sampai 21)
| 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.
Minggu 4: window function (hari 22 sampai 28)
| 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 dan 30: dua studi kasus gabungan
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.
Cara ngerjain biar nggak berhenti di tengah
- Kerjain di jam yang sama tiap hari. Ritme lebih menentukan dari durasi.
- Tulis dulu rencananya dalam bahasa biasa sebelum ngetik SQL. Satu kalimat cukup.
- Cek jumlah baris hasilnya masuk akal atau nggak. Hasil 40.000 baris dari 2.000 pesanan artinya JOIN kamu meledak.
- Kalau macet lebih dari satu jam, berhenti. Baca pembahasannya, tutup, terus tulis ulang dari nol besok pagi.
- Simpan semua query di satu file. Akhir bulan kamu punya catatan sendiri yang lebih berguna dari cheatsheet mana pun.
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.
FAQ
Butuh berapa lama per hari?
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.
Database apa yang dipakai?
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.
Harus urut dari hari 1 atau boleh loncat?
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.
Soal mana yang paling sering bikin macet?
Hari 9 soal LEFT JOIN dengan IS NULL, hari 19 soal beda UNION dan UNION ALL, dan hari 27 soal frame ROWS BETWEEN.
Latihan ini cukup buat lolos interview?
Cukup buat bagian teknisnya. Yang belum ketutup adalah kemampuan ngobrol: mengklarifikasi soal, menjelaskan logika sambil ngetik, dan menyadari kasus tepi.
Penutup
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.
Artikel terkait
Cara Membuat Pivot Dinamis di SQL (Panduan 2026)
Pivot dinamis di SQL ngubah baris jadi kolom tanpa kamu hardcode nama kolomnya. Ini cara bikinnya pakai CASE WHEN dan versi yang kolomnya ngikut data.
Menghitung YTD, QTD, dan MTD di SQL (Panduan 2026)
YTD, QTD, dan MTD ngukur total dari awal tahun, kuartal, atau bulan sampai hari ini. Ini cara ngitungnya di SQL pakai DATE_TRUNC, plus satu query gabungan.
Gap and Island Analysis di SQL
Gap and island analysis di SQL nyari deret data yang berturut-turut (island) dan celah yang bolong (gap). Cocok buat hitung streak login atau cari tanggal transaksi yang hilang.