Satu baris kembar bisa berdampak lebih besar daripada ribuan baris salah format: pendapatan terhitung ganda, jumlah pelanggan melonjak tanpa sebab, dan dashboard manajemen kehilangan kepercayaan. Duplikat jarang terlihat pada query — SELECT tetap berjalan dan angka tetap keluar, hanya saja salah. Sumbernya bermacam-macam: pipeline yang dijalankan ulang setelah kegagalan, sistem pengantar pesan yang mengirim ulang, atau penggabungan data dari dua cabang sistem. Chapter ini memetakan asal duplikat, membedakan dua jenis dedup, lalu mempraktikkan pola keep-latest dengan ROW_NUMBER() di SQL dan dict di Python.
Dari Mana Duplikat Berasal #
Tiga sumber berikut menutup mayoritas kasus di pipeline batch:
- Reprocess — pipeline dijalankan ulang setelah kegagalan di tengah jalan; batch yang sebagian berhasil termuat terpasang dua kali. Pola append pada chapter Pemuatan Data paling rentan terhadap kasus ini.
- At-least-once delivery — sistem pengantar pesan menjamin setiap peristiwa sampai minimal sekali: bila pengirim tidak menerima konfirmasinya, pesan dikirim ulang dan penerima mendapat salinan kedua.
- Penggabungan sumber ganda — data pelanggan dari aplikasi lama dan aplikasi baru digabung; satu orang tercatat di kedua sistem dengan kunci berbeda.
At-least-once adalah kompromi yang lazim karena lawannya, exactly-once, mahal dijamin sistem terdistribusi. Akibatnya, deduplikasi menjadi tanggung jawab pipeline — peristiwa ganda harus dibuang sebelum sampai ke tabel analitik.
Dedup Kunci vs Dedup Konten #
| Aspek | Dedup kunci | Dedup konten |
|---|---|---|
| Dianggap sama bila | nilai kuncinya identik (mis. pelanggan_id) | seluruh kolom bernilai identik |
| Biaya deteksi | murah — cukup PARTITION BY kunci | mahal — seluruh kolom dibandingkan |
| Cocok untuk | peristiwa berulang per entitas: update profil pelanggan | baris yang benar-benar salinan: log pengiriman ulang |
| Risiko bila salah pakai | dua peristiwa sah berbeda bisa terdeteksi sebagai satu | dua baris sah yang kebetulan identik ikut terbuang |
Pada data peristiwa pelanggan, setiap baris memuat kunci entitas (pelanggan_id) dan waktu kejadian (updated_at). Dua baris dengan kunci sama bukan galat — keduanya versi berbeda dari satu orang; dedup kunci bertugas memilih versi mana yang dipertahankan.
Keep-Latest dengan ROW_NUMBER #
Tabel berikut berisi lima peristiwa untuk dua pelanggan — tiga versi untuk pelanggan 1 dan dua versi untuk pelanggan 2. Aturan bisnisnya: simpan satu baris per pelanggan, ambil versi dengan updated_at paling baru.
import sqlite3
koneksi = sqlite3.connect(":memory:")
koneksi.execute(
"CREATE TABLE peristiwa_pelanggan ("
"pelanggan_id INTEGER, updated_at TEXT, kota TEXT, status TEXT)"
)
baris_masuk = [
(1, "2026-10-01 08:00", "Jakarta", "aktif"),
(1, "2026-10-05 09:30", "Depok", "aktif"), # id 1, paling baru
(2, "2026-10-02 10:00", "Bandung", "aktif"),
(1, "2026-09-28 07:15", "Bekasi", "nonaktif"), # id 1, paling lama
(2, "2026-10-06 11:00", "Cimahi", "aktif"), # id 2, paling baru
]
koneksi.executemany("INSERT INTO peristiwa_pelanggan VALUES (?, ?, ?, ?)", baris_masuk)
# Dedup: satu baris per pelanggan_id, ambil updated_at terbaru
koneksi.execute("""
CREATE TABLE pelanggan_terbaru AS
SELECT pelanggan_id, kota, status, updated_at
FROM (
SELECT
pelanggan_id, kota, status, updated_at,
ROW_NUMBER() OVER (
PARTITION BY pelanggan_id
ORDER BY updated_at DESC
) AS nomor
FROM peristiwa_pelanggan
)
WHERE nomor = 1
""")
print("Baris masuk :", len(baris_masuk))
for baris in koneksi.execute(
"SELECT * FROM pelanggan_terbaru ORDER BY pelanggan_id"):
print(baris)
koneksi.close()Output yang Dihasilkan:
Baris masuk : 5
(1, 'Depok', 'aktif', '2026-10-05 09:30')
(2, 'Cimahi', 'aktif', '2026-10-06 11:00')Mari kita telaah bagian-bagian kunci di atas:
- Baris 8-15 (data ganda): pelanggan 1 muncul tiga kali dan pelanggan 2 dua kali — lima baris masuk akan menjadi dua baris keluar.
- Baris 24-27 (penomoran versi):
ROW_NUMBER() OVER (PARTITION BY pelanggan_id ORDER BY updated_at DESC)memberi nomor 1, 2, 3 di dalam tiap kelompok kunci; karena urutannya menurun berdasarkanupdated_at, nomor 1 selalu jatuh pada versi termuda. - Baris 30 (filter):
WHERE nomor = 1memilih tepat satu baris per kunci — pelanggan 1 tersimpan sebagai Depok (5 Oktober), menggantikan versi Bekasi yang lebih tua.
Format updated_at seperti "2026-10-05 09:30" membuat urutan kronologis sama dengan urutan leksikografis string — itulah sebabnya ORDER BY pada kolom teks tetap benar di sini. Bila timestamp memakai zona waktu berbeda atau format tidak seragam, urutkan setelah dinormalisasi.
Dedup di Python dengan dict #
Versi Python memakai sifat dictionary: satu kunci hanya menyimpan satu nilai, sehingga penimpaan secara alami membuang versi lama. Dataset sama dipakai ulang agar hasilnya bisa dibandingkan langsung.
peristiwa = [
{"pelanggan_id": 1, "updated_at": "2026-10-01 08:00", "kota": "Jakarta"},
{"pelanggan_id": 1, "updated_at": "2026-10-05 09:30", "kota": "Depok"},
{"pelanggan_id": 2, "updated_at": "2026-10-02 10:00", "kota": "Bandung"},
{"pelanggan_id": 1, "updated_at": "2026-09-28 07:15", "kota": "Bekasi"},
{"pelanggan_id": 2, "updated_at": "2026-10-06 11:00", "kota": "Cimahi"},
]
# keep-latest: penimpaan -> kunci menyimpan baris terakhir yang datang
terbaru = {}
for p in peristiwa:
terbaru[p["pelanggan_id"]] = p
print("keep-latest (urutan datang):")
for pid in sorted(terbaru):
print(pid, terbaru[pid]["kota"], terbaru[pid]["updated_at"])
# Perbaikan: urutkan berdasarkan updated_at sebelum menimpa
terbaru_urut = {}
for p in sorted(peristiwa, key=lambda x: x["updated_at"]):
terbaru_urut[p["pelanggan_id"]] = p
print("keep-latest (setelah diurutkan):")
for pid in sorted(terbaru_urut):
print(pid, terbaru_urut[pid]["kota"], terbaru_urut[pid]["updated_at"])
# keep-first: setdefault -> kunci menyimpan baris pertama yang datang
pertama = {}
for p in peristiwa:
pertama.setdefault(p["pelanggan_id"], p)
print("keep-first:")
for pid in sorted(pertama):
print(pid, pertama[pid]["kota"], pertama[pid]["updated_at"])Output yang Dihasilkan:
keep-latest (urutan datang):
1 Bekasi 2026-09-28 07:15
2 Cimahi 2026-10-06 11:00
keep-latest (setelah diurutkan):
1 Depok 2026-10-05 09:30
2 Cimahi 2026-10-06 11:00
keep-first:
1 Jakarta 2026-10-01 08:00
2 Bandung 2026-10-02 10:00Blok pertama dan kedua pada output adalah pelajaran terpenting chapter ini. Penimpaan tanpa pengurutan menyimpan baris terakhir yang datang — untuk pelanggan 1, baris itu justru versi Bekasi tertanggal 28 September, versi paling tua. Urutan kedatangan bukan urutan waktu; setelah daftar diurutkan berdasarkan updated_at, hasil Python menjadi identik dengan hasil SQL: Depok dan Cimahi.
Sementara itu, setdefault pada blok ketiga menolak menimpa kunci yang sudah terisi — itulah cara one-liner untuk keep-first, tepat ketika baris pertama secara historis adalah yang otoritatif (misalnya data pendaftaran yang tidak boleh berubah). Perilaku mapping Python didokumentasikan di docs.python.org
; sintaks ROW_NUMBER dan PARTITION BY ada di referensi bahasa SQLite
.
Dedup menuntut pipeline tahu bagian mana yang baru dan bagian mana yang sudah pernah diproses — pertanyaan itu membawa kita ke Incremental Transformation: memproses data sejak titik waktu tertentu tanpa mengulang seluruh history.