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.

TipePerilaku terhadap perubahanRiwayatContoh cocok
Type 0nilai tidak pernah diubahtidak adatanggal registrasi pelanggan
Type 1nilai lama ditimpa langsungtidak adakoreksi salah ketik nama
Type 2versi baru disisipkan sebagai baris barupenuhkota pelanggan, kategori produk
Type 3nilai lama dipindah ke kolom previoussatu tingkatkategori produk yang baru berganti
Type 4atribut sering berubah dipisah ke mini-dimensionper atributsaldo 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.

python
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:

text
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:

  1. Baris 21-28 (sisip_versi): semua penyisipan versi baru lewat satu fungsi — baris baru selalu mendapat valid_to sentinel dan is_current = 1, lalu fungsi mengembalikan SK hasil autoincrement.
  2. Baris 33-36 (deteksi): query mencari baris is_current = 1 milik pelanggan; hasil None berarti pelanggan baru, hasil dengan kota sama berarti tidak ada perubahan.
  3. Baris 42-45 (tutup baris lama): UPDATE menulis valid_to menjadi tanggal load dan menurunkan is_current ke 0 — baris lama tetap ada sebagai riwayat, tidak dihapus.
  4. Baris 46 (sisip versi baru): pemanggilan sisip_versi membuat baris Depok dengan valid_from 2026-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.

python
# --- 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:

text
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.

python
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:

text
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.