Aplikasi bertambah tua, dan skemanya ikut bertambah: kolom baru demi fitur baru, index demi query yang melambat, tipe diperbaiki karena keputusan desain berubah. Masalahnya, perubahan itu terjadi di tiga tempat sekaligus — laptop pengembang, server staging, server produksi — dan tanpa disiplin, ketiganya perlahan berbeda isi. Query yang “di laptop saya jalan” gagal di staging; dump produksi tidak bisa dimuat karena tabel dev sudah berubah bentuk. Database migrations adalah jawaban standar industri untuk masalah ini: setiap perubahan skema menjadi file kecil bernomor, dieksekusi berurutan oleh satu runner, dengan tabel pelacak di database yang mencatat versi mana yang sudah masuk. Dalam chapter ini kita membangun sistem migration mini itu sendiri — dua file SQL, satu runner Python yang benar-benar berjalan — lalu memetakan tooling populer dan disiplin tim di sekelilingnya.

Konsep: File Bernomor + Tabel Pelacak #

Dua komponen membentuk sistem migration. Pertama, file-file SQL bernomor — satu file berisi satu perubahan, namanya diawali nomor urut (001_, 002_, …) sehingga urutan eksekusi terbaca langsung dari daftar file. Kedua, tabel pelacak — biasanya dinamai schema_migrations — yang menyimpan versi-versi yang sudah diterapkan. Runner membaca daftar file, membandingkan dengan isi tabel pelacak, lalu menjalankan hanya file yang belum tercatat, mencatatnya setelah berhasil.

Alur kerjanya membentuk lingkaran yang sama di semua tool:

Dengan catatan versi di dalam database (bukan di file konfigurasi laptop), kondisi skema dan kondisi pelacak selalu berpindah bersama: dump yang direstore membawa catatannya sendiri, dan runner yang sama menghasilkan skema yang sama di environment mana pun — sifat yang disebut idempoten.

Dua File Migration Pertama #

Berikut isi kedua file di folder migrations/ bundle chapter ini. File pertama melahirkan tabel produk:

sql
-- @notest
-- 001_create_produk.sql — tabel produk versi pertama.

CREATE TABLE produk (
  id    INT PRIMARY KEY,
  nama  VARCHAR(100) NOT NULL,
  harga INT NOT NULL
);

File kedua datang berminggu-minggu kemudian — fitur manajemen stok meminta kolom baru. Ia tidak menulis ulang file pertama (file itu sudah terapkan di mana-mana), melainkan menambah file baru yang mengubah tabel hasil file pertama:

sql
-- @notest
-- 002_add_kolom.sql — tambah kolom stok pada tabel produk.

ALTER TABLE produk
  ADD COLUMN stok INT NOT NULL DEFAULT 0;

Nomor urut di nama file adalah kontrak eksekusi: 001 sebelum 002, selalu. Nilai DEFAULT 0 pada kolom baru membuat ALTER aman untuk tabel yang sudah berisi baris — setiap baris lama mendapat nilai stok nol tanpa error.

Runner Kecil: migrate.py #

Runner kita berupa satu file Python pendek — migrate.py, tersimpan di bundle chapter (bagian Berkas Sumber), membaca folder migrations/ di sebelahnya:

python
"""migrate.py — runner migration sederhana untuk database belajar_app.

Jalankan dari folder chapter:  python3 migrate.py
Bisa dijalankan berulang kali: migration yang sudah tercatat dilewati.
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""

import os
from pathlib import Path

import pymysql

FOLDER_MIGRATION = Path(__file__).resolve().parent / "migrations"
NAMA_DATABASE = "belajar_app"

port = int(os.environ.get("DB_PORT", "3306"))
koneksi = pymysql.connect(host="127.0.0.1", user="root", password="", port=port)

with koneksi.cursor() as kursor:
    kursor.execute(f"CREATE DATABASE IF NOT EXISTS {NAMA_DATABASE}")
    kursor.execute(f"USE {NAMA_DATABASE}")
    kursor.execute(
        "CREATE TABLE IF NOT EXISTS schema_migrations (versi VARCHAR(100) PRIMARY KEY)"
    )
    kursor.execute("SELECT versi FROM schema_migrations")
    sudah = {baris[0] for baris in kursor.fetchall()}

terpakai = 0
for berkas in sorted(FOLDER_MIGRATION.glob("*.sql")):
    versi = berkas.stem
    if versi in sudah:
        print(f"lewati    {versi} (sudah terapkan)")
        continue
    isi = berkas.read_text(encoding="utf-8")
    with koneksi.cursor() as kursor:
        for statement in isi.split(";"):
            if statement.strip():
                kursor.execute(statement)
        kursor.execute(
            "INSERT INTO schema_migrations (versi) VALUES (%s)", (versi,)
        )
    koneksi.commit()
    terpakai += 1
    print(f"terapkan  {versi} ... OK")

print(f"selesai: {terpakai} migration baru, {len(sudah) + terpakai} total terapkan")
koneksi.close()

Bedah baris-per-barisnya:

  1. FOLDER_MIGRATION = Path(__file__).resolve().parent / "migrations": lokasi folder dihitung relatif terhadap file migrate.py sendiri — bukan direktori kerja — sehingga runner bisa dipanggil dari mana pun.
  2. pymysql.connect(...) tanpa database=: sengaja. Pada eksekusi pertama, database belajar_app belum ada; koneksi dengan database="belajar_app" akan langsung gagal dengan error 1049. Runner sendirilah yang membuatnya.
  3. CREATE DATABASE IF NOT EXISTS + USE: menyiapkan wadah bila belum ada, lalu memilihnya untuk sesi ini. USE dari cursor.execute() berlaku untuk koneksi sepanjang hidupnya.
  4. CREATE TABLE IF NOT EXISTS schema_migrations: tabel pelacak — cukup satu kolom versi sebagai PRIMARY KEY. Tool sungguhan menambah kolom waktu terapkan dan siapa yang menjalankannya; kita cukup catat versinya.
  5. SELECT versi FROM schema_migrations → sudah = {...}: himpunan versi yang sudah masuk, dibaca sekali di awal. Bandingkan: himpunan (set) memungkinkan uji keanggotaan versi in sudah yang cepat.
  6. sorted(FOLDER_MIGRATION.glob("*.sql")): daftar file SQL diurutkan berdasar nama — itulah mengapa prefiks nomor tiga digit begitu penting (001 mengalahkan 002 secara alfabetis).
  7. if versi in sudah: continue: jantung idempotensi. File yang sudah tercatat dilewati tanpa dibuka.
  8. isi.split(";") + kursor.execute(statement): file dipecah per-statement lalu dieksekusi satu per satu. Ini parser paling sederhana yang bisa dibayangkan — cukup untuk file latihan kita; tool sungguhan memakai parser SQL penuh yang tidak terkecoh oleh titik koma di dalam string.
  9. INSERT INTO schema_migrations ... VALUES (%s) + commit(): versi dicatat setelah seluruh statement file berhasil — satu file migration berjalan sebagai satu kesatuan: tabel berubah dan catatan terpasang bersama-sama, atau keduanya tidak.
  10. Baris laporan print(...): setiap aksi tercetak — terapkan, lewati, dan ringkasan akhir — sehingga eksekusi runner bisa dibaca manusia.

Eksekusi Pertama: Dua Migration Masuk #

Saat database masih segar, runner menerapkan kedua file, lalu kita periksa hasilnya langsung dari klien — DESCRIBE membuktikan kolom stok dari file kedua benar-benar hadir:

bash
-- @test-bash
DB_PORT=3306 python3 content/mysql/integrasi-aplikasi/database-migrations/migrate.py
mysql -h 127.0.0.1 -P 3306 -u root -t -e "DESCRIBE belajar_app.produk;"

Output yang Dihasilkan:

text
terapkan  001_create_produk ... OK
terapkan  002_add_kolom ... OK
selesai: 2 migration baru, 2 total terapkan
+-------+--------------+------+-----+---------+-------+
| Field | Type         | Null | Key | Default | Extra |
+-------+--------------+------+-----+---------+-------+
| id    | int          | NO   | PRI | NULL    |       |
| nama  | varchar(100) | NO   |     | NULL    |       |
| harga | int          | NO   |     | NULL    |       |
| stok  | int          | NO   |     | 0       |       |
+-------+--------------+------+-----+---------+-------+

Empat baris laporan runner (terapkan 001, terapkan 002, ringkasan) diikuti struktur tabel: tiga kolom asli plus stok INT bernilai bawaan 0 — jejak migration kedua.

Eksekusi Kedua: Idempoten #

Menjalankan runner untuk kedua kalinya adalah ujian terpenting sistem ini. Panggilan kedua, lalu isi tabel pelacak:

bash
-- @test-bash
DB_PORT=3306 python3 content/mysql/integrasi-aplikasi/database-migrations/migrate.py
mysql -h 127.0.0.1 -P 3306 -u root -t -e "SELECT * FROM belajar_app.schema_migrations;"

Output yang Dihasilkan:

text
lewati    001_create_produk (sudah terapkan)
lewati    002_add_kolom (sudah terapkan)
selesai: 0 migration baru, 2 total terapkan
+-------------------+
| versi             |
+-------------------+
| 001_create_produk |
| 002_add_kolom     |
+-------------------+

Runner kini mencetak lewati untuk kedua file dan 0 migration baru — tidak ada CREATE TABLE dijalankan kedua kali, tidak ada error “table already exists”. Tabel pelacak menunjukkan dua baris: satu per file yang pernah masuk. Inilah perilaku yang membuat aman menjalankan runner di setiap deploy: environment yang mutakhir selesai dalam sekejap, environment yang tertinggal mengejar dengan jumlah file yang belum tercatat.

Tooling Populer #

Runner 40 baris kita sudah menangkap konsepnya; di proyek nyata kita jarang menulisnya sendiri karena tooling dewasa sudah menyelesaikan sisa pekerjaan yang kita sederhanakan (parser SQL, transaksi per-file, deteksi perubahan file yang sudah tercatat):

ToolEkosistemWujud migration
Flywaylintas bahasa (JVM)file SQL bernomor V1__deskripsi.sql — paling dekat dengan pola chapter ini
Liquibaselintas bahasa (JVM)changeset XML/YAML/JSON/SQL dengan pelacak DATABASECHANGELOG
AlembicPython (SQLAlchemy)skrip Python; bisa dibuat otomatis dari perubahan model ORM
PrismaNode.js/TypeScriptskema di schema.prisma; migration SQL digenerate, pelacak _prisma_migrations

Semua tool itu memelihara tabel pelacak versinya masing-masing di database target — nama tabelnya berbeda (flyway_schema_history, DATABASECHANGELOG, alembic_version, _prisma_migrations), idenya identik dengan schema_migrations milik kita.

Arah Maju, Rollback, dan Disiplin Tim #

Sebagian tool mendukung skrip mundur (down migration) — file kebalikan yang menghapus kolom atau tabel — sehingga environment bisa “dimundurkan” ke versi sebelumnya. Praktik industri modern lebih banyak memilih kebijakan forward-only: bila migration ternyata salah, kita tidak menghapus jejaknya, melainkan menulis migration ketiga yang memperbaiki (misalnya 003_drop_kolom_stok.sql). Rollback pada database berisi data produksi jarang aman: ALTER yang menambah kolom bisa mundur mulus, tetapi migration yang menggabungkan dua tabel tidak bisa “dipecah kembali” — data yang hilang di jalan maju tidak dikembalikan oleh jalan mundur.

Karena migration mengubah struktur yang dipakai semua orang, ia diperlakukan seperti kode: masuk pull request, direview rekan, diuji di staging sebelum produksi. Review migration menimbang hal yang jarang muncul di review kode biasa — berapa lama ALTER akan mengunci tabel di tabel besar, apakah kolom baru punya nilai bawaan, apakah index baru benar-benar dipakai query yang ada.

Baselining untuk Database yang Sudah Hidup #

Bagaimana menerapkan disiplin ini pada database yang sudah berumur dan belum punya pelacak? Jangan tulis ulang sejarahnya. Ambil snapshot skema saat ini (misalnya hasil mysqldump --no-data), jadikan snapshot itu 001_skema_awal.sql, lalu tandai sebagai sudah terapkan dengan menulis langsung ke pelacak:

sql
-- @notest
INSERT IGNORE INTO schema_migrations (versi) VALUES ('001_skema_awal');

INSERT IGNORE membuat perintah ini aman dijalankan berulang kali — baris hanya masuk bila versinya belum ada. Mulai titik itu, semua perubahan berikutnya mengikuti alur normal: file baru bernomor, direview, diterapkan runner.

Membersihkan Latihan #

bash
-- @test-bash
mysql -h 127.0.0.1 -P 3306 -u root -e "DROP DATABASE IF EXISTS belajar_app;"

Referensi #

Migration membangun struktur yang konsisten; struktur yang sama tetap kosong tanpa isi. Bagaimana kita mengisi tabel-tabel itu dengan data awal yang bermakna untuk pengembangan dan testing — secara berulang kali dan tanpa menduplikasi baris — adalah pekerjaan chapter berikutnya: Database Seeding.

Berkas Sumber

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

migrate.py

Unduh berkas
python
"""migrate.py — runner migration sederhana untuk database belajar_app.

Jalankan dari folder chapter:  python3 migrate.py
Bisa dijalankan berulang kali: migration yang sudah tercatat dilewati.
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""

import os
from pathlib import Path

import pymysql

FOLDER_MIGRATION = Path(__file__).resolve().parent / "migrations"
NAMA_DATABASE = "belajar_app"

port = int(os.environ.get("DB_PORT", "3306"))
koneksi = pymysql.connect(host="127.0.0.1", user="root", password="", port=port)

with koneksi.cursor() as kursor:
    kursor.execute(f"CREATE DATABASE IF NOT EXISTS {NAMA_DATABASE}")
    kursor.execute(f"USE {NAMA_DATABASE}")
    kursor.execute(
        "CREATE TABLE IF NOT EXISTS schema_migrations (versi VARCHAR(100) PRIMARY KEY)"
    )
    kursor.execute("SELECT versi FROM schema_migrations")
    sudah = {baris[0] for baris in kursor.fetchall()}

terpakai = 0
for berkas in sorted(FOLDER_MIGRATION.glob("*.sql")):
    versi = berkas.stem
    if versi in sudah:
        print(f"lewati    {versi} (sudah terapkan)")
        continue
    isi = berkas.read_text(encoding="utf-8")
    with koneksi.cursor() as kursor:
        for statement in isi.split(";"):
            if statement.strip():
                kursor.execute(statement)
        kursor.execute(
            "INSERT INTO schema_migrations (versi) VALUES (%s)", (versi,)
        )
    koneksi.commit()
    terpakai += 1
    print(f"terapkan  {versi} ... OK")

print(f"selesai: {terpakai} migration baru, {len(sudah) + terpakai} total terapkan")
koneksi.close()

migrations/001_create_produk.sql

Unduh berkas
sql
-- 001_create_produk.sql — tabel produk versi pertama.

CREATE TABLE produk (
  id    INT PRIMARY KEY,
  nama  VARCHAR(100) NOT NULL,
  harga INT NOT NULL
);

migrations/002_add_kolom.sql

Unduh berkas
sql
-- 002_add_kolom.sql — tambah kolom stok pada tabel produk.

ALTER TABLE produk
  ADD COLUMN stok INT NOT NULL DEFAULT 0;