Skema database tidak pernah selesai: sprint ini menambah kolom telepon, sprint berikutnya memasang unique index, tiga bulan lagi ada tabel baru. Tanpa disiplin, skema di laptop pengembang, server staging, dan produksi perlahan berbeda satu sama lain, dan bug hanya muncul di lingkungan tertentu. Database migration menyelesaikan masalah itu dengan memperlakukan skema sebagai kode: setiap perubahan hidup dalam berkas bernomor urut yang dibaca dari atas ke bawah, disimpan di repositori, direview seperti kode biasa, dan dijalankan oleh sebuah runner yang mencatat versi mana yang sudah diterapkan. Chapter ini membangun sistem migrasi kecil yang benar-benar berjalan — tiga migrasi untuk Toko Maju Jaya, tabel pelacakan schema_migrations, runner Python, rollback, lalu peta tool migrasi yang dipakai industri.

Skema sebagai Kode #

Prinsipnya sederhana: satu berkas per perubahan, bernomor urut, sekali jalan. Konvensi penamaan yang kita pakai mengikuti pola NNNN_nama.up.sql dan NNNN_nama.down.sql:

text
database-migrations/
├── 0001_buat_tabel.up.sql
├── 0001_buat_tabel.down.sql
├── 0002_tambah_kolom_telepon.up.sql
├── 0002_tambah_kolom_telepon.down.sql
├── 0003_buat_index_email.up.sql
├── 0003_buat_index_email.down.sql
├── migrasi-terapkan.py
├── migrasi-status.py
└── migrasi-rollback.py

Berkas .up.sql mendefinisikan perubahan maju (membuat objek, menambah kolom); berkas .down.sql mendefinisikan kebalikannya untuk rollback. Nomor versi menentukan urutan penerapan, dan nama deskriptif membuat git log skema terbaca seperti riwayat fitur. Konvensi serupa dipakai lintas tool: Flyway memakai V1__deskripsi.sql, golang-migrate memakai 0001_deskripsi.up.sql — bentuknya beda, kontraknya sama.

Tiga Migrasi untuk Toko Maju Jaya #

Migrasi pertama membuat tabel pelanggan dengan identity primary key. Perhatikan bahwa email sengaja belum diberi keunikan di sini — keunikan datang sebagai migrasi ketiga, sehingga kita melihat skema tumbuh lewat langkah-langkah kecil:

sql
-- 0001_buat_tabel.up.sql
CREATE TABLE pelanggan (
    id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama  text NOT NULL,
    email text NOT NULL
);
sql
-- 0001_buat_tabel.down.sql
DROP TABLE pelanggan;

Migrasi kedua menambahkan kolom telepon — perubahan ALTER TABLE yang paling sering terjadi di aplikasi hidup:

sql
-- 0002_tambah_kolom_telepon.up.sql
ALTER TABLE pelanggan ADD COLUMN telepon text;
sql
-- 0002_tambah_kolom_telepon.down.sql
ALTER TABLE pelanggan DROP COLUMN telepon;

Migrasi ketiga memasang unique index pada email. Membuat keunikan sebagai langkah terpisah juga realistis: di dunia nyata, tabel sering sudah berisi data kotor yang harus dibersihkan sebelum constraint bisa dipasang:

sql
-- 0003_buat_index_email.up.sql
CREATE UNIQUE INDEX ix_pelanggan_email ON pelanggan (email);
sql
-- 0003_buat_index_email.down.sql
DROP INDEX ix_pelanggan_email;

Enam berkas di atas adalah seluruh “sumber kebenaran” skema. Runner yang kita bangun berikutnya hanya butuh dua hal darinya: nomor versi dari nama berkas, dan isi berkas untuk dieksekusi.

Runner Migrasi Minimal #

Runner membaca tabel pelacakan schema_migrations — satu baris per versi yang sudah diterapkan — lalu menjalankan berkas .up.sql yang nomornya belum tercatat. Setiap migrasi dieksekusi dalam satu transaksi yang juga memuat pencatatan versinya, sehingga skema dan tabel pelacakan tidak pernah saling mendahului:

python migrasi-terapkan.py
#!/usr/bin/env python3
# Runner migrasi minimal: menerapkan berkas *.up.sql yang belum tercatat
# di tabel schema_migrations, satu transaksi per migrasi.
import os
import re

import psycopg

BASE = os.path.dirname(os.path.abspath(__file__))
POLA = re.compile(r"^(\d+)_(.+)\.up\.sql$")

conn = psycopg.connect()
conn.execute("""
    CREATE TABLE IF NOT EXISTS schema_migrations (
        version    bigint PRIMARY KEY,
        name       text NOT NULL,
        applied_at timestamptz NOT NULL DEFAULT now()
    )
""")
conn.commit()

terpasang = {v for (v,) in conn.execute(
    "SELECT version FROM schema_migrations").fetchall()}
# Tutup transaksi yang dibuka pembacaan di atas, supaya blok
# conn.transaction() di bawah berjalan sebagai transaksi teratas.
conn.commit()

tertunda = []
for berkas in sorted(os.listdir(BASE)):
    m = POLA.match(berkas)
    if m and int(m.group(1)) not in terpasang:
        tertunda.append((int(m.group(1)), m.group(2), berkas))

if not tertunda:
    print("tidak ada migrasi tertunda")

for versi, nama, berkas in tertunda:
    sql = open(os.path.join(BASE, berkas)).read()
    # Satu transaksi per migrasi: DDL di PostgreSQL bersifat transaksional,
    # jadi kegagalan di tengah berkas tidak meninggalkan skema setengah jadi.
    with conn.transaction():
        conn.execute(sql)
        conn.execute(
            "INSERT INTO schema_migrations (version, name) VALUES (%s, %s)",
            (versi, nama),
        )
    print(f"menerapkan {versi:04d}_{nama}")

for versi, nama in conn.execute(
        "SELECT version, name FROM schema_migrations ORDER BY version").fetchall():
    print(f"terpasang: {versi:04d}_{nama}")

conn.close()

Output yang Dihasilkan:

text
menerapkan 0001_buat_tabel
menerapkan 0002_tambah_kolom_telepon
menerapkan 0003_buat_index_email
terpasang: 0001_buat_tabel
terpasang: 0002_tambah_kolom_telepon
terpasang: 0003_buat_index_email

Mari kita bedah bagian-bagian kunci runner tersebut:

  1. CREATE TABLE IF NOT EXISTS schema_migrations — tabel pelacakan dibuat idempoten: pemanggilan pertama membuat tabel, pemanggilan berikutnya cukup melewatkannya. Kolom applied_at memakai now() sebagai nilai bawaan untuk kebutuhan audit, bukan untuk ditampilkan.
  2. POLA dan os.listdir(BASE) — nomor versi dan nama diekstrak dari nama berkas, bukan dari isi berkas; sorted() menjamin urutan penerapan 0001, 0002, 0003.
  3. conn.commit() setelah pembacaan — psycopg membuka transaksi implisit pada setiap execute(); transaksi pembacaan harus ditutup dulu agar blok conn.transaction() pada loop berjalan sebagai transaksi teratas, bukan sebagai savepoint di dalam transaksi baca.
  4. with conn.transaction(): — satu transaksi membungkus DDL migrasi dan INSERT ke schema_migrations; bila berkas gagal di tengah, keduanya batal bersama-sama.
  5. Cetak menerapkan setelah blok keluar — baris hanya dicetak setelah transaksi commit, jadi log tidak pernah mengklaim migrasi yang sebenarnya gagal.

Memeriksa Status #

Skrip status membandingkan berkas yang tersedia di direktori dengan versi yang tercatat di database — alat paling sering dipakai saat memeriksa lingkungan yang tidak sinkron:

python migrasi-status.py
#!/usr/bin/env python3
# Laporan status migrasi: versi terpasang vs berkas yang tersedia.
import os
import re

import psycopg

BASE = os.path.dirname(os.path.abspath(__file__))
POLA = re.compile(r"^(\d+)_(.+)\.up\.sql$")

conn = psycopg.connect()
conn.execute("""
    CREATE TABLE IF NOT EXISTS schema_migrations (
        version    bigint PRIMARY KEY,
        name       text NOT NULL,
        applied_at timestamptz NOT NULL DEFAULT now()
    )
""")
conn.commit()

ada = {}
for berkas in sorted(os.listdir(BASE)):
    m = POLA.match(berkas)
    if m:
        ada[int(m.group(1))] = m.group(2)

terpasang = {v for (v,) in conn.execute(
    "SELECT version FROM schema_migrations").fetchall()}

for versi in sorted(ada):
    status = "terpasang" if versi in terpasang else "tertunda"
    print(f"{versi:04d}_{ada[versi]}: {status}")

conn.close()

Output yang Dihasilkan:

text
0001_buat_tabel: terpasang
0002_tambah_kolom_telepon: terpasang
0003_buat_index_email: terpasang

Output menunjukkan ketiga versi berstatus terpasang — runner sudah menjalankan semuanya dan tabel pelacakan sesuai dengan isi direktori. Bila seorang rekan menambahkan 0004_hapus_kolom_telepon.up.sql lalu Anda menarik perubahan itu, skrip inilah yang pertama menunjukkan 0004: tertunda sebelum runner dipanggil.

Rollback Satu Langkah #

Rollback menjalankan berkas .down.sql milik versi terakhir, lalu menghapus catatannya — skema dan tabel pelacakan tetap sejalan karena keduanya berada dalam transaksi yang sama:

python migrasi-rollback.py
#!/usr/bin/env python3
# Rollback satu langkah: jalankan berkas .down.sql milik versi terakhir
# yang terpasang, lalu hapus catatannya dari schema_migrations.
import os

import psycopg

BASE = os.path.dirname(os.path.abspath(__file__))

conn = psycopg.connect()
baris = conn.execute(
    "SELECT version, name FROM schema_migrations "
    "ORDER BY version DESC LIMIT 1"
).fetchone()
# Tutup transaksi pembacaan agar blok rollback menjadi transaksi teratas.
conn.commit()

if baris is None:
    print("tidak ada migrasi untuk di-rollback")
else:
    versi, nama = baris
    berkas = os.path.join(BASE, f"{versi:04d}_{nama}.down.sql")
    sql = open(berkas).read()
    # Transaksi yang sama mencakup DDL dan penghapusan catatan versi,
    # sehingga skema dan tabel pelacakan selalu sejalan.
    with conn.transaction():
        conn.execute(sql)
        conn.execute(
            "DELETE FROM schema_migrations WHERE version = %s", (versi,))
    print(f"rollback {versi:04d}_{nama}")
    print(f"menjalankan berkas {os.path.basename(berkas)}")

for versi, nama in conn.execute(
        "SELECT version, name FROM schema_migrations ORDER BY version").fetchall():
    print(f"terpasang: {versi:04d}_{nama}")

conn.close()

Output yang Dihasilkan:

text
rollback 0003_buat_index_email
menjalankan berkas 0003_buat_index_email.down.sql
terpasang: 0001_buat_tabel
terpasang: 0002_tambah_kolom_telepon

Versi 0003_buat_index_email turun: DROP INDEX ix_pelanggan_email dijalankan, catatannya dihapus, dan yang tersisa hanya 0001–0002. Memanggil runner yang sama kembali menyelesaikan siklus ini — idempotensi tabel pelacakan membuat runner aman dipanggil ulang kapan saja:

#!/usr/bin/env bash
# Runner yang sama dipanggil ulang setelah rollback:
# hanya versi 0003 yang tertunda, versi 0001-0002 dilewati.
python3 migrasi-terapkan.py

Output yang Dihasilkan:

text
menerapkan 0003_buat_index_email
terpasang: 0001_buat_tabel
terpasang: 0002_tambah_kolom_telepon
terpasang: 0003_buat_index_email

Runner hanya menerapkan 0003_buat_index_email karena 0001 dan 0002 masih tercatat sebagai terpasang. Perilaku “cari yang tertunda, jalankan, catat” inilah yang membuat migrasi aman dijalankan berulang kali di lingkungan mana pun, oleh siapa pun, termasuk di CI dan saat deploy.

Siklus Hidup Migrasi #

Migrasi bukan sekadar berkas di repo — ia mengalir melalui siklus yang sama dengan kode aplikasi:

TahapTempatYang terjadi
TulisMesin pengembangMigrasi baru dibuat, dijalankan lokal, diuji bersama kode
ReviewPull requestDiff skema dibaca reviewer seperti diff kode; efek pada data dibahas
CIPipeline otomatisDatabase kosong dibangun dari migrasi penuh, lalu test suite berjalan di atasnya
DeployServer staging, lalu produksiRunner menerapkan migrasi tertunda sebelum/saat rilis versi aplikasi

Tahap CI adalah penjaga terpenting: database yang dibangun dari nol lewat seluruh rangkaian migrasi membuktikan bahwa rantai migrasi masih bisa menghasilkan skema yang benar, bukan hanya skema lama yang ditambal.

Praktik Menulis Migrasi #

Aturan main yang menjaga rantai migrasi tetap sehat selama bertahun-tahun:

PraktikAlasan
Langkah kecil, satu maksud per berkasReview mudah, gagal cepat, rollback presisi
Selalu sediakan berkas .down.sqlRollback bisa diuji otomatis di CI
Jangan pernah mengedit migrasi yang sudah jalan di luar mesin AndaLingkungan lain sudah mencatat versi itu; perubahan tidak akan pernah diterapkan ulang — tulis migrasi baru
Uji di salinan data produksiALTER TABLE pada tabel 50 juta baris berbeda drastis dari tabel 10 baris
Waspadakan durasi lockBeberapa perubahan menahan ACCESS EXCLUSIVE lock; pisahkan pekerjaan berat jadi beberapa langkah
Pisahkan perubahan skema dan backfill dataSkema naik dulu, data diisi bertahap, constraint dipasang terakhir

Peta Tool Migrasi #

Runner 60 baris kita cukup untuk memahami mekanismenya, tetapi di produksi tim biasanya memakai tool yang sudah teruji:

ToolEkosistemCiri khas
FlywayLintas bahasaKonvensi V1__nama.sql, sederhana dan luas dipakai
LiquibaseLintas bahasaPerubahan dalam XML/YAML/SQL, punya diff skema
AlembicPython/SQLAlchemyMigrasi otomatis dari model ORM, autogenerate
golang-migrateGoPola NNN_nama.up.sql/.down.sql, CLI ramping
node-db-migrateNode.jsAPI JavaScript plus berkas SQL
SqitchLintas bahasaModel plan+change+revert tanpa penomoran versi
psql + MakefileTanpa dependensiKurang lebih runner kita, diperluas skrip shell

Semua tool memegang prinsip yang sama dengan yang kita bangun: berkas berversi, tabel pelacakan, transaksi per langkah. Migrasi ke tool mana pun jadi mudah justru karena konsepnya sudah Anda pegang.

Kebijakan Forward-Only #

Berkas .down.sql berguna di mesin pengembang dan di CI untuk memverifikasi bahwa perubahan memang reversible. Namun di produksi, kebijakan yang lebih jujur adalah forward-only: saat migrasi menimbulkan masalah, Anda menulis migrasi perbaikan baru yang bergerak maju, bukan rollback. Alasannya sederhana namun tegas — migrasi up yang sudah berjalan sering mengubah data (backfill, penggabungan kolom, pengecekan ulang nilai), dan rollback skema tidak akan mengembalikan data ke bentuk sebelumnya. Database produksi yang di-rollback sebagian berakhir dalam keadaan yang tidak pernah diuji siapa pun. Simpan rollback untuk lingkungan yang bisa dibangun ulang dari nol.

Dengan skema kini hidup sebagai rangkaian berkas berversi, pertanyaan berikutnya muncul alami: database kosong hasil migrasi masih perlu diisi data awal sebelum aplikasi bisa dipakai dan diuji. Mengisi data awal itu pekerjaan chapter berikutnya, Database Seeding.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

0001_buat_tabel.down.sql

Unduh berkas
sql
-- 0001_buat_tabel.down.sql
DROP TABLE pelanggan;

0001_buat_tabel.up.sql

Unduh berkas
sql
-- 0001_buat_tabel.up.sql
CREATE TABLE pelanggan (
    id    integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama  text NOT NULL,
    email text NOT NULL
);

0002_tambah_kolom_telepon.down.sql

Unduh berkas
sql
-- 0002_tambah_kolom_telepon.down.sql
ALTER TABLE pelanggan DROP COLUMN telepon;

0002_tambah_kolom_telepon.up.sql

Unduh berkas
sql
-- 0002_tambah_kolom_telepon.up.sql
ALTER TABLE pelanggan ADD COLUMN telepon text;

0003_buat_index_email.down.sql

Unduh berkas
sql
-- 0003_buat_index_email.down.sql
DROP INDEX ix_pelanggan_email;

0003_buat_index_email.up.sql

Unduh berkas
sql
-- 0003_buat_index_email.up.sql
CREATE UNIQUE INDEX ix_pelanggan_email ON pelanggan (email);

migrasi-rollback.py

Unduh berkas
python
#!/usr/bin/env python3
# Rollback satu langkah: jalankan berkas .down.sql milik versi terakhir
# yang terpasang, lalu hapus catatannya dari schema_migrations.
import os

import psycopg

BASE = os.path.dirname(os.path.abspath(__file__))

conn = psycopg.connect()
baris = conn.execute(
    "SELECT version, name FROM schema_migrations "
    "ORDER BY version DESC LIMIT 1"
).fetchone()
# Tutup transaksi pembacaan agar blok rollback menjadi transaksi teratas.
conn.commit()

if baris is None:
    print("tidak ada migrasi untuk di-rollback")
else:
    versi, nama = baris
    berkas = os.path.join(BASE, f"{versi:04d}_{nama}.down.sql")
    sql = open(berkas).read()
    # Transaksi yang sama mencakup DDL dan penghapusan catatan versi,
    # sehingga skema dan tabel pelacakan selalu sejalan.
    with conn.transaction():
        conn.execute(sql)
        conn.execute(
            "DELETE FROM schema_migrations WHERE version = %s", (versi,))
    print(f"rollback {versi:04d}_{nama}")
    print(f"menjalankan berkas {os.path.basename(berkas)}")

for versi, nama in conn.execute(
        "SELECT version, name FROM schema_migrations ORDER BY version").fetchall():
    print(f"terpasang: {versi:04d}_{nama}")

conn.close()

migrasi-status.py

Unduh berkas
python
#!/usr/bin/env python3
# Laporan status migrasi: versi terpasang vs berkas yang tersedia.
import os
import re

import psycopg

BASE = os.path.dirname(os.path.abspath(__file__))
POLA = re.compile(r"^(\d+)_(.+)\.up\.sql$")

conn = psycopg.connect()
conn.execute("""
    CREATE TABLE IF NOT EXISTS schema_migrations (
        version    bigint PRIMARY KEY,
        name       text NOT NULL,
        applied_at timestamptz NOT NULL DEFAULT now()
    )
""")
conn.commit()

ada = {}
for berkas in sorted(os.listdir(BASE)):
    m = POLA.match(berkas)
    if m:
        ada[int(m.group(1))] = m.group(2)

terpasang = {v for (v,) in conn.execute(
    "SELECT version FROM schema_migrations").fetchall()}

for versi in sorted(ada):
    status = "terpasang" if versi in terpasang else "tertunda"
    print(f"{versi:04d}_{ada[versi]}: {status}")

conn.close()

migrasi-terapkan.py

Unduh berkas
python
#!/usr/bin/env python3
# Runner migrasi minimal: menerapkan berkas *.up.sql yang belum tercatat
# di tabel schema_migrations, satu transaksi per migrasi.
import os
import re

import psycopg

BASE = os.path.dirname(os.path.abspath(__file__))
POLA = re.compile(r"^(\d+)_(.+)\.up\.sql$")

conn = psycopg.connect()
conn.execute("""
    CREATE TABLE IF NOT EXISTS schema_migrations (
        version    bigint PRIMARY KEY,
        name       text NOT NULL,
        applied_at timestamptz NOT NULL DEFAULT now()
    )
""")
conn.commit()

terpasang = {v for (v,) in conn.execute(
    "SELECT version FROM schema_migrations").fetchall()}
# Tutup transaksi yang dibuka pembacaan di atas, supaya blok
# conn.transaction() di bawah berjalan sebagai transaksi teratas.
conn.commit()

tertunda = []
for berkas in sorted(os.listdir(BASE)):
    m = POLA.match(berkas)
    if m and int(m.group(1)) not in terpasang:
        tertunda.append((int(m.group(1)), m.group(2), berkas))

if not tertunda:
    print("tidak ada migrasi tertunda")

for versi, nama, berkas in tertunda:
    sql = open(os.path.join(BASE, berkas)).read()
    # Satu transaksi per migrasi: DDL di PostgreSQL bersifat transaksional,
    # jadi kegagalan di tengah berkas tidak meninggalkan skema setengah jadi.
    with conn.transaction():
        conn.execute(sql)
        conn.execute(
            "INSERT INTO schema_migrations (version, name) VALUES (%s, %s)",
            (versi, nama),
        )
    print(f"menerapkan {versi:04d}_{nama}")

for versi, nama in conn.execute(
        "SELECT version, name FROM schema_migrations ORDER BY version").fetchall():
    print(f"terpasang: {versi:04d}_{nama}")

conn.close()

ulangi-terapkan.sh

Unduh berkas
bash
#!/usr/bin/env bash
# Runner yang sama dipanggil ulang setelah rollback:
# hanya versi 0003 yang tertunda, versi 0001-0002 dilewati.
python3 migrasi-terapkan.py