Dimensi menggambarkan dunia nyata, dan dunia nyata berubah: pelanggan pindah kota, produk berganti kategori, toko berganti nama. Fact table sebaliknya merekam kejadian masa lalu yang tidak akan pernah berubah. Ketika Budi pindah dari Jakarta ke Depok, warehouse menghadapi pertanyaan serius: apakah laporan penjualan Jakarta tahun lalu ikut berubah, atau riwayat kota Budi disimpan baris demi baris? Slowly Changing Dimensions (SCD) adalah kerangka untuk menjawab pertanyaan tersebut secara sadar, bukan karena kebetulan.
Ragam Tipe SCD #
Istilah slowly changing menegaskan bahwa atribut dimensi berubah, tetapi tidak setiap hari — cukup sering sehingga tidak bisa diabaikan, cukup jarang sehingga satu baris per versi masih masuk akal. Kimball mengklasifikasikan beberapa tipe penanganan; empat tipe pertama paling sering dipakai.
| Tipe | Perilaku terhadap perubahan | Riwayat | Contoh cocok |
|---|---|---|---|
| Type 0 | nilai tidak pernah diubah | tidak ada | tanggal registrasi pelanggan |
| Type 1 | nilai lama ditimpa langsung | tidak ada | koreksi salah ketik nama |
| Type 2 | versi baru disisipkan sebagai baris baru | penuh | kota pelanggan, kategori produk |
| Type 3 | nilai lama dipindah ke kolom previous | satu tingkat | kategori produk yang baru berganti |
| Type 4 | atribut sering berubah dipisah ke mini-dimension | per atribut | saldo poin loyalitas |
Type 2 adalah pilihan default warehouse karena mempertahankan riwayat penuh, sehingga bab ini membedah Type 2 paling dalam; Type 1 dan Type 3 dibahas ringkas setelahnya.
Struktur Kolom SCD Type 2 #
Dimensi SCD Type 2 menyimpan satu baris per versi data. Tiga kolom tambahan menandai masa berlaku setiap versi: valid_from (tanggal versi mulai berlaku), valid_to (tanggal versi berhenti berlaku), dan is_current (penanda versi terkini). Versi yang masih berlaku diberi valid_to sentinel 9999-12-31 dan is_current = 1, sehingga query “dimensi terkini” cukup menyaring satu flag tanpa membandingkan tanggal.
Saat Budi pindah kota pada 2026-01-20, dimensi memuat dua baris untuk Budi: versi Jakarta dengan valid_from 2026-01-10 sampai valid_to 2026-01-20, lalu versi Depok sejak 2026-01-20. Setiap versi tetap memakai surrogate key sendiri hasil Chapter Surrogate Key.
Implementasi: Deteksi, Tutup Baris Lama, Sisip Versi Baru #
Proses load SCD Type 2 berjalan tiga langkah: deteksi perubahan dengan membandingkan data sumber ke baris is_current = 1, tutup baris lama dengan UPDATE pada valid_to dan is_current, lalu sisipkan versi baru. Skrip berikut menjalankan dua load berturut-turut ke dimensi pelanggan.
import sqlite3
koneksi = sqlite3.connect(":memory:")
cur = koneksi.cursor()
# dimensi pelanggan SCD Type 2: satu baris per versi data
cur.execute("""
CREATE TABLE dim_pelanggan (
sk_pelanggan INTEGER PRIMARY KEY,
email TEXT NOT NULL,
kota TEXT NOT NULL,
valid_from TEXT NOT NULL,
valid_to TEXT NOT NULL, -- 9999-12-31 = masih berlaku
is_current INTEGER NOT NULL
)
""")
TGL_AKHIR = "9999-12-31"
def sisip_versi(email, kota, dari):
"""Sisipkan satu versi baru yang berlaku sejak tanggal `dari`."""
cur.execute(
"""INSERT INTO dim_pelanggan (email, kota, valid_from, valid_to, is_current)
VALUES (?, ?, ?, ?, 1)""",
(email, kota, dari, TGL_AKHIR),
)
return cur.lastrowid
def muat_pelanggan(email, kota, tgl):
"""SCD Type 2: deteksi perubahan, tutup baris lama, sisip versi baru."""
baris = cur.execute(
"SELECT sk_pelanggan, kota FROM dim_pelanggan WHERE email = ? AND is_current = 1",
(email,),
).fetchone()
if baris is None:
return "pelanggan baru, sk %d" % sisip_versi(email, kota, tgl)
sk, kota_lama = baris
if kota_lama == kota:
return "tidak berubah, sk %d" % sk
cur.execute(
"UPDATE dim_pelanggan SET valid_to = ?, is_current = 0 WHERE sk_pelanggan = ?",
(tgl, sk),
)
return "tutup sk %d, buat sk %d" % (sk, sisip_versi(email, kota, tgl))
print("Load 1 (2026-01-10):")
print(" budi:", muat_pelanggan("[email protected]", "Jakarta", "2026-01-10"))
print(" siti:", muat_pelanggan("[email protected]", "Bandung", "2026-01-10"))
print("Load 2 (2026-01-20, Budi pindah ke Depok):")
print(" budi:", muat_pelanggan("[email protected]", "Depok", "2026-01-20"))
print(" siti:", muat_pelanggan("[email protected]", "Bandung", "2026-01-20"))
print()
print("Isi dim_pelanggan setelah dua load:")
for baris in cur.execute(
"""SELECT sk_pelanggan, email, kota, valid_from, valid_to, is_current
FROM dim_pelanggan ORDER BY sk_pelanggan"""
):
print(" ", baris)Output yang Dihasilkan:
Load 1 (2026-01-10):
budi: pelanggan baru, sk 1
siti: pelanggan baru, sk 2
Load 2 (2026-01-20, Budi pindah ke Depok):
budi: tutup sk 1, buat sk 3
siti: tidak berubah, sk 2
Isi dim_pelanggan setelah dua load:
(1, '[email protected]', 'Jakarta', '2026-01-10', '2026-01-20', 0)
(2, '[email protected]', 'Bandung', '2026-01-10', '9999-12-31', 1)
(3, '[email protected]', 'Depok', '2026-01-20', '9999-12-31', 1)Mari kita telaah baris-baris kode di atas:
- Baris 21-28 (
sisip_versi): semua penyisipan versi baru lewat satu fungsi — baris baru selalu mendapatvalid_tosentinel danis_current = 1, lalu fungsi mengembalikan SK hasil autoincrement. - Baris 33-36 (deteksi): query mencari baris
is_current = 1milik pelanggan; hasilNoneberarti pelanggan baru, hasil dengan kota sama berarti tidak ada perubahan. - Baris 42-45 (tutup baris lama):
UPDATEmenulisvalid_tomenjadi tanggal load dan menurunkanis_currentke 0 — baris lama tetap ada sebagai riwayat, tidak dihapus. - Baris 46 (sisip versi baru): pemanggilan
sisip_versimembuat baris Depok denganvalid_from2026-01-20; inilah pasangan tutup-dan-sisip yang menjadi inti SCD Type 2.
Fakta Join pada Waktu Kejadian #
Fact table SCD Type 2 menyimpan SK versi dimensi yang berlaku saat kejadian, bukan versi terkini. Potongan berikutnya dari berkas yang sama menerjemahkan setiap pesanan menjadi SK versi yang benar, lalu menggabungkan fakta kembali ke dimensi.
# --- fact table menyimpan SK versi yang berlaku saat kejadian ---
cur.execute("""
CREATE TABLE fact_pesanan (
id_pesanan TEXT PRIMARY KEY,
sk_pelanggan INTEGER NOT NULL,
tanggal TEXT NOT NULL,
total INTEGER NOT NULL
)
""")
def sk_saat_kejadian(email, tgl):
"""Pilih versi dimensi dengan interval half-open [valid_from, valid_to)."""
(sk,) = cur.execute(
"""SELECT sk_pelanggan FROM dim_pelanggan
WHERE email = ? AND valid_from <= ? AND ? < valid_to""",
(email, tgl, tgl),
).fetchone()
return sk
for idp, email, tgl, total in [
("ORD-1", "[email protected]", "2026-01-12", 150000),
("ORD-2", "[email protected]", "2026-01-25", 200000),
("ORD-3", "[email protected]", "2026-01-25", 300000),
]:
cur.execute(
"INSERT INTO fact_pesanan VALUES (?, ?, ?, ?)",
(idp, sk_saat_kejadian(email, tgl), tgl, total),
)
print()
print("Fakta join ke versi dimensi saat kejadian:")
for baris in cur.execute(
"""SELECT f.id_pesanan, f.tanggal, d.kota, f.total
FROM fact_pesanan f
JOIN dim_pelanggan d ON d.sk_pelanggan = f.sk_pelanggan
ORDER BY f.tanggal"""
):
print(" ", baris)Output yang Dihasilkan:
Fakta join ke versi dimensi saat kejadian:
('ORD-1', '2026-01-12', 'Jakarta', 150000)
('ORD-2', '2026-01-25', 'Depok', 200000)
('ORD-3', '2026-01-25', 'Bandung', 300000)Pesanan ORD-1 tanggal 12 Januari jatuh pada interval versi Jakarta, sedangkan ORD-2 tanggal 25 Januari jatuh pada versi Depok — laporan penjualan per kota tetap akurat untuk kedua periode. Berkat interval half-open, pesanan tepat pada 20 Januari akan pasti milik versi baru.
SCD Type 1 dan Type 3 dalam Satu Pandangan #
Type 1 dan Type 3 menangani kasus ketika riwayat penuh tidak dibutuhkan. Type 1 cukup menimpa nilai lama — cocok untuk koreksi data. Type 3 menyimpan satu nilai sebelumnya pada kolom terpisah — cukup untuk pertanyaan “sebelum dan sesudah” tanpa menambah baris.
import sqlite3
koneksi = sqlite3.connect(":memory:")
cur = koneksi.cursor()
# SCD Type 1: nilai lama langsung ditimpa
cur.execute("CREATE TABLE dim_p_v1 (email TEXT PRIMARY KEY, nama TEXT, kota TEXT)")
cur.execute("INSERT INTO dim_p_v1 VALUES ('[email protected]', 'Budi', 'Jakarta')")
cur.execute("UPDATE dim_p_v1 SET kota = 'Depok' WHERE email = '[email protected]'")
print("SCD 1 (timpa) :", cur.execute("SELECT * FROM dim_p_v1").fetchall())
# SCD Type 3: satu nilai sebelumnya disimpan di kolom terpisah
cur.execute(
"CREATE TABLE dim_p_v3 (email TEXT PRIMARY KEY, nama TEXT,"
" kota TEXT, kota_sebelumnya TEXT)"
)
cur.execute(
"INSERT INTO dim_p_v3 VALUES ('[email protected]', 'Budi', 'Jakarta', NULL)"
)
cur.execute(
"""UPDATE dim_p_v3 SET kota_sebelumnya = kota, kota = 'Depok'
WHERE email = '[email protected]'"""
)
print("SCD 3 (previous):", cur.execute("SELECT * FROM dim_p_v3").fetchall())Output yang Dihasilkan:
SCD 1 (timpa) : [('[email protected]', 'Budi', 'Depok')]
SCD 3 (previous): [('[email protected]', 'Budi', 'Depok', 'Jakarta')]Pada Type 1, nilai Jakarta benar-benar tergantikan sehingga laporan historis ikut membaca Depok. Pada Type 3, satu tingkat riwayat bertahan di kota_sebelumnya; perpindahan kedua akan menimpa Jakarta karena hanya satu slot yang tersedia.
Alat transformasi modern menyediakan SCD Type 2 siap pakai: dbt, misalnya, memiliki fitur snapshot yang otomatis menutup dan menyisipkan versi — dokumentasinya ada di docs.getdbt.com , sementara rujukan sintaks SQL yang dipakai di atas tersedia di dokumentasi SQLite . SCD menjawab perubahan atribut dimensi dari load ke load; bagaimana bila kita perlu mengetahui keadaan pada setiap titik waktu secara berkala, seperti saldo stok setiap hari? Pertanyaan tersebut dijawab oleh pola tabel pada Chapter Snapshot Tables.