Aplikasi hidup: minggu depan toko kita butuh kolom stok_minimum untuk notifikasi restock, bulan depan tabel penjualan menyusul. Masalahnya, skema database tidak bisa diedit seperti kode Python — tabel yang sudah berisi data tidak bisa “dibuat ulang” seenaknya, dan mengubah skema di satu komputer harus dicerminkan di komputer rekan kerja dan server produksi. Migration adalah pola industri untuk masalah ini: setiap perubahan skema menjadi berkas SQL bernomor yang dijalankan tepat satu kali, berurutan, dan tercatat di dalam database itu sendiri.

Masalah Evolusi Skema #

Bayangkan toko.db sudah berisi 500 baris produk ketika kita sadar butuh kolom baru. Menghapus file dan menjalankan CREATE TABLE baru berarti memusnahkan 500 baris data bisnis. Mengubah skema manual dengan ALTER TABLE dari REPL berlaku hanya di satu mesin — dan tidak meninggalkan jejak perubahan apa pun untuk rekan kerja yang membuka database yang sama. Kita butuh cara memperlakukan perubahan skema sebagai kode: tersimpan di berkas, bisa dibaca, bisa dijalankan ulang dengan aman.

Pola Berkas Migrasi #

Setiap perubahan skema menjadi satu berkas SQL dengan nomor urut di awal nama. Untuk toko kita, tiga migrasi pertama berbentuk berikut:

sql
CREATE TABLE produk (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL,
    harga REAL NOT NULL,
    stok INTEGER NOT NULL DEFAULT 0
);
sql
ALTER TABLE produk ADD COLUMN stok_minimum INTEGER NOT NULL DEFAULT 5;
sql
CREATE TABLE penjualan (
    id INTEGER PRIMARY KEY,
    produk_id INTEGER NOT NULL REFERENCES produk(id),
    jumlah INTEGER NOT NULL
);

Struktur proyeknya sederhana:

text
toko/
├── migrate.py
└── migrations/
    ├── 001_create_produk.sql
    ├── 002_tambah_kolom_stok_minimum.sql
    └── 003_create_penjualan.sql

Kunci polanya: urutan ditentukan nomor di nama berkas, bukan waktu pembuatan berkas. Nomor membesar ke depan — 001, 002, 003 — dan tidak pernah dipakai ulang, bahkan setelah sebuah migrasi diganti migrasi baru.

Runner Migrasi #

Runner adalah program kecil yang menjawab dua pertanyaan: migrasi mana saja yang sudah terpasang, dan berkas mana saja yang belum. Pencatatan dilakukan lewat tabel bantu schema_migrations di dalam database itu sendiri:

python
from pathlib import Path
import sqlite3

DB_PATH = Path("toko.db")
MIGRASI_DIR = Path("migrations")

def tabel_migrasi(conn):
    conn.execute("""CREATE TABLE IF NOT EXISTS schema_migrations (
        versi INTEGER PRIMARY KEY,
        nama TEXT NOT NULL
    )""")

def versi_terpasang(conn):
    return {v for (v,) in conn.execute("SELECT versi FROM schema_migrations")}

def jalankan_migrasi():
    conn = sqlite3.connect(DB_PATH)
    try:
        tabel_migrasi(conn)
        terpasang = versi_terpasang(conn)
        diterapkan = 0
        for berkas in sorted(MIGRASI_DIR.glob("*.sql")):
            versi = int(berkas.name.split("_")[0])
            if versi in terpasang:
                continue
            print(f"Menerapkan {berkas.name} ...")
            conn.executescript(berkas.read_text())
            conn.execute(
                "INSERT INTO schema_migrations (versi, nama) VALUES (?, ?)",
                (versi, berkas.name),
            )
            conn.commit()
            diterapkan += 1
        if diterapkan:
            print(f"{diterapkan} migrasi diterapkan.")
        else:
            print("Skema sudah terbaru, tidak ada migrasi baru.")
    finally:
        conn.close()

Path(DB_PATH).unlink(missing_ok=True)  # demo diulang dari awal
jalankan_migrasi()
print()
print("Menjalankan runner untuk kedua kali:")
jalankan_migrasi()

Output yang Dihasilkan:

text
Menerapkan 001_create_produk.sql ...
Menerapkan 002_tambah_kolom_stok_minimum.sql ...
Menerapkan 003_create_penjualan.sql ...
3 migrasi diterapkan.

Menjalankan runner untuk kedua kali:
Skema sudah terbaru, tidak ada migrasi baru.

Mari kita telaah bagian-bagian kuncinya:

  1. Baris 7-11 (def tabel_migrasi(...)): membuat tabel pencatat schema_migrations bila belum ada — database tahu riwayatnya sendiri, di mesin mana pun.
  2. Baris 13-14 (def versi_terpasang(...)): mengumpulkan nomor versi yang sudah masuk database sebagai himpunan (set) untuk pemeriksaan cepat.
  3. Baris 22-25 (for berkas in sorted(...)): memindai folder migrations dalam urutan nama; int(berkas.name.split("_")[0]) mengambil 1 dari 001_create_produk.sql.
  4. Baris 24-25 (if versi in terpasang: continue): migrasi yang sudah tercatat dilewati — inilah yang membuat runner aman dijalankan berulang kali, terbukti oleh baris “Skema sudah terbaru” pada transkrip.
  5. Baris 26-33 (print, executescript, INSERT INTO schema_migrations): conn.executescript() menjalankan seluruh isi berkas SQL (boleh berisi beberapa pernyataan), lalu nomornya dicatat dan di-commit bersama.
  6. Baris 41-45 (alur demo): file database dihapus agar demo bisa diulang, runner dipanggil dua kali, dan transkrip membuktikan perbedaan perilakunya.

Setelah runner bekerja, kita bisa memverifikasi hasilnya dari database yang sama:

python
import sqlite3

conn = sqlite3.connect("toko.db")
print("Riwayat migrasi:")
for versi, nama in conn.execute(
    "SELECT versi, nama FROM schema_migrations ORDER BY versi"
):
    print(versi, nama)

print("Kolom produk:", [r[1] for r in conn.execute("PRAGMA table_info(produk)")])
conn.close()

Output yang Dihasilkan:

text
Riwayat migrasi:
1 001_create_produk.sql
2 002_tambah_kolom_stok_minimum.sql
3 003_create_penjualan.sql
Kolom produk: ['id', 'nama', 'harga', 'stok', 'stok_minimum']

PRAGMA table_info(produk) membuktikan migrasi 002 benar-benar menambahkan kolom stok_minimum ke tabel yang sudah ada sejak migrasi 001 — evolusi skema tanpa menyentuh data lama.

Prinsip Forward-Only dan Tools Industri #

Sekali migrasi dijalankan di luar mesin development, berkasnya tidak boleh diedit — perbaikan ditulis sebagai migrasi baru bernomor lebih besar. Prinsip forward-only ini menjaga riwayat tetap jujur; database yang telah memakai 002 tidak akan pernah melihat versi lamanya lagi. Runner kita sengaja hanya berjalan maju, dan di tim yang lebih besar, pekerjaan ini biasanya diserahkan ke tools:

ToolsEkosistemCara kerja
AlembicSQLAlchemy / ORMmigrasi dari perubahan model Python, lengkap dengan upgrade dan downgrade
Django migrationsDjangobawaan framework, membuat berkas migrasi Python otomatis dari perubahan model
dbmate / Flywaybahasa-agnosticmemindai folder berkas SQL persis seperti runner kita

Ketiga tools itu menyimpan ide yang sama dengan runner kita — versi tercatat, berjalan maju, aman diulang — hanya dengan fitur lebih. Memahami mekanikanya dari awal membuat kita tidak panik saat Alembic menolak berjalan suatu hari nanti.