Skema database hampir tidak pernah selesai: kolom baru ditambahkan, tabel dipecah, index dibuat untuk query yang melambat. Bila perubahan tersebut dilakukan dengan menjalankan ALTER TABLE manual di terminal, lingkungan pengembangan, staging, dan produksi perlahan berbeda — hingga satu hari aplikasi gagal di produksi karena tabel yang di sana tidak punya kolom yang diharapkan kode. Migration menyelesaikan masalah ini dengan menukar perubahan skema ad-hoc menjadi berkas SQL bernomor yang diterapkan berurutan dan tercatat di database itu sendiri. Pada chapter ini kita pelajari format berkas migration, tabel schema_migrations, perilaku naik (up) dan turun (down), lalu membangun runner sederhana yang bekerja di atas fs.FS.

Anatomi Berkas Migration #

Satu migration adalah satu berkas SQL dengan nomor versi di awal nama. Nomor tersebut menentukan urutan penerapan: 001 sebelum 002, apa pun tanggal pembuatannya. Setiap versi punya dua sisi — berkas up menerapkan perubahan, berkas down membatalkannya:

text
migrations/
├── 001_create_items.up.sql
├── 001_create_items.down.sql
├── 002_add_harga.up.sql
└── 002_add_harga.down.sql

Isi pasangan pertama pada contoh di atas:

sql
-- 001_create_items.up.sql
CREATE TABLE items (
    id SERIAL PRIMARY KEY,
    nama TEXT NOT NULL
);

-- 001_create_items.down.sql
DROP TABLE items;

Runner mencatat versi yang sudah diterapkan pada satu tabel khusus — biasanya bernama schema_migrations — berisi versi dan waktu penerapan. Saat startup, aplikasi membandingkan isi tabel tersebut dengan daftar berkas migration, lalu menjalankan hanya berkas yang belum tercatat. Konsekuensinya: migration lama tidak pernah dijalankan dua kali, dan perbaikan atas migration yang sudah diterapkan ditulis sebagai migration baru.

Tooling Migration #

Kita tidak perlu menulis runner sendiri di proyek nyata. Tiga tool populer di ekosistem Go:

ToolCiri khasCocok untuk
golang-migrateCLI + library, berkas .up.sql/.down.sql, banyak driverProyek yang ingin kontrol penuh lewat SQL murni
gooseKomentar directive -- +goose Up di dalam berkas SQL, alternatif fungsi GoMigration SQL yang juga memuat seeding data
atlasSkema deklaratif (HCL) yang di-diff untuk menghasilkan migrationTim yang menyukai pendekatan desired-state

Ketiganya aktif dikembangkan dan mendukung embedding migration ke binary lewat embed.FS. Pilihan tool tidak mengubah prinsip dasarnya: skema berubah lewat berkas bernomor yang tercatat, bukan lewat perintah manual di terminal.

Runner Sederhana di Atas fs.FS #

Runner minimal hanya butuh dua bahan: daftar berkas terurut dan catatan versi. Package io/fs menyediakan kontrak fs.FS sehingga runner tidak peduli dari mana berkas berasal — folder di disk, embed.FS, atau fstest.MapFS untuk pengujian. Berkas migration-demo.go membangun runner lengkap dengan Up, Down, dan catatan versi in-memory; SQL-nya hanya dicetak karena lingkungan materi ini tidak memiliki PostgreSQL aktif. Berikut isi utuh berkas tersebut:

go
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"slices"
	"strconv"
	"strings"
	"testing/fstest"
)

// Runner adalah migration runner sederhana yang bekerja atas fs.FS.
// Field applied meniru tabel schema_migrations di database nyata.
type Runner struct {
	applied map[int]bool
}

func NewRunner() *Runner {
	return &Runner{applied: map[int]bool{}}
}

// pisahNama memecah "002_add_harga.up.sql" menjadi versi 2 dan label "add_harga".
func pisahNama(nama string) (int, string, error) {
	nama = strings.TrimSuffix(nama, ".sql")
	nama = strings.TrimSuffix(nama, ".up")
	nama = strings.TrimSuffix(nama, ".down")
	angka, label, ada := strings.Cut(nama, "_")
	if !ada {
		return 0, "", fmt.Errorf("nama migration tidak valid: %s", nama)
	}
	v, err := strconv.Atoi(angka)
	if err != nil {
		return 0, "", fmt.Errorf("nomor versi tidak valid: %s", nama)
	}
	return v, label, nil
}

// Up menerapkan semua migration .up.sql yang belum diterapkan, dari versi terkecil.
// "Eksekusi" SQL di sini hanya dicetak karena tidak ada database aktif.
func (r *Runner) Up(fsys fs.FS) error {
	names, err := daftarBerkas(fsys)
	if err != nil {
		return err
	}
	for _, name := range names {
		if !strings.HasSuffix(name, ".up.sql") {
			continue
		}
		v, label, err := pisahNama(name)
		if err != nil {
			return err
		}
		if r.applied[v] {
			fmt.Printf("lewati     %03d_%s (sudah diterapkan)\n", v, label)
			continue
		}
		sql, err := fs.ReadFile(fsys, name)
		if err != nil {
			return err
		}
		fmt.Printf("terapkan   %03d_%s: %s\n", v, label, sql)
		r.applied[v] = true
	}
	return nil
}

// Down membatalkan migration dengan versi terbesar, satu langkah saja.
func (r *Runner) Down(fsys fs.FS) error {
	terapan := r.terapannya()
	if len(terapan) == 0 {
		return errors.New("tidak ada migration untuk dibatalkan")
	}
	versi := terapan[len(terapan)-1]
	names, err := daftarBerkas(fsys)
	if err != nil {
		return err
	}
	for _, name := range names {
		if !strings.HasSuffix(name, ".down.sql") {
			continue
		}
		v, label, errN := pisahNama(name)
		if errN != nil {
			return errN
		}
		if v != versi {
			continue
		}
		sql, errR := fs.ReadFile(fsys, name)
		if errR != nil {
			return errR
		}
		fmt.Printf("batalkan   %03d_%s: %s\n", v, label, sql)
		delete(r.applied, v)
		return nil
	}
	return fmt.Errorf("berkas down untuk versi %d tidak ditemukan", versi)
}

// terapannya mengembalikan daftar versi yang sudah diterapkan, terurut naik.
func (r *Runner) terapannya() []int {
	hasil := make([]int, 0, len(r.applied))
	for v := range r.applied {
		hasil = append(hasil, v)
	}
	slices.Sort(hasil)
	return hasil
}

func daftarBerkas(fsys fs.FS) ([]string, error) {
	entries, err := fs.ReadDir(fsys, ".")
	if err != nil {
		return nil, err
	}
	names := make([]string, 0, len(entries))
	for _, e := range entries {
		names = append(names, e.Name())
	}
	slices.Sort(names)
	return names, nil
}

func main() {
	// MapFS menggantikan folder migrations/ di proyek nyata.
	// Di aplikasi sungguhan, gunakan embed.FS atau folder di disk.
	migrationsFS := fstest.MapFS{
		"001_create_items.up.sql":   &fstest.MapFile{Data: []byte("CREATE TABLE items (id SERIAL PRIMARY KEY, nama TEXT)")},
		"001_create_items.down.sql": &fstest.MapFile{Data: []byte("DROP TABLE items")},
		"002_add_harga.up.sql":      &fstest.MapFile{Data: []byte("ALTER TABLE items ADD COLUMN harga INTEGER")},
		"002_add_harga.down.sql":    &fstest.MapFile{Data: []byte("ALTER TABLE items DROP COLUMN harga")},
		"003_add_stok.up.sql":       &fstest.MapFile{Data: []byte("ALTER TABLE items ADD COLUMN stok INTEGER NOT NULL DEFAULT 0")},
		"003_add_stok.down.sql":     &fstest.MapFile{Data: []byte("ALTER TABLE items DROP COLUMN stok")},
	}

	runner := NewRunner()

	fmt.Println("== up pertama: semua migration baru diterapkan ==")
	if err := runner.Up(migrationsFS); err != nil {
		fmt.Println("galat:", err)
		return
	}
	fmt.Println("versi terapannya:", runner.terapannya())

	fmt.Println("== up kedua: migration lama dilewati ==")
	if err := runner.Up(migrationsFS); err != nil {
		fmt.Println("galat:", err)
		return
	}

	fmt.Println("== down satu langkah: versi terbesar dibatalkan ==")
	if err := runner.Down(migrationsFS); err != nil {
		fmt.Println("galat:", err)
		return
	}
	fmt.Println("versi terapannya:", runner.terapannya())
}

Jalankan programnya dari direktori chapter:

bash
go run migration-demo.go

Output yang Dihasilkan:

text
== up pertama: semua migration baru diterapkan ==
terapkan   001_create_items: CREATE TABLE items (id SERIAL PRIMARY KEY, nama TEXT)
terapkan   002_add_harga: ALTER TABLE items ADD COLUMN harga INTEGER
terapkan   003_add_stok: ALTER TABLE items ADD COLUMN stok INTEGER NOT NULL DEFAULT 0
versi terapannya: [1 2 3]
== up kedua: migration lama dilewati ==
lewati     001_create_items (sudah diterapkan)
lewati     002_add_harga (sudah diterapkan)
lewati     003_add_stok (sudah diterapkan)
== down satu langkah: versi terbesar dibatalkan ==
batalkan   003_add_stok: ALTER TABLE items DROP COLUMN stok
versi terapannya: [1 2]

Tiga perilaku inti runner terlihat pada output: up pertama menerapkan berkas baru dari versi terkecil, up kedua melewati versi yang sudah tercatat, dan down membatalkan satu versi terbesar. Mari kita telaah bagian-bagian pentingnya:

  1. Baris 15-17 (type Runner): menyimpan applied map[int]bool — tiruan tabel schema_migrations di memori.
  2. Baris 46-48 (filter .up.sql): hanya berkas up yang diproses saat naik; berkas down diabaikan agar tidak ikut diterapkan.
  3. Baris 54-57 (cek applied): versi yang sudah tercatat dilewati — penerapan ulang tidak terjadi.
  4. Baris 71-74 (Down): terapannya() sudah terurut naik, sehingga versi terbesar tinggal diambil dari elemen terakhir sebelum mencari berkas down yang cocok.
  5. Baris 127-135 (MapFS): fstest.MapFS menyediakan keenam berkas SQL dari memori. Ganti argumen Up dan Down dengan embed.FS atau os.DirFS("migrations") dan runner bekerja tanpa perubahan lain.

Embedding Migration ke Binary #

Aplikasi yang di-deploy sebagai satu binary sebaiknya membawa migration-nya sendiri, sehingga versi kode dan versi skema tidak pernah terpisah. Directive //go:embed mengubah folder SQL menjadi nilai embed.FS, yang merupakan implementasi dari fs.FS:

go
import "embed"

//go:embed migrations/*.sql
var migrationsFS embed.FS

func main() {
        runner := NewRunner()
        if err := runner.Up(migrationsFS); err != nil {
                log.Fatal(err)
        }
}

Runner pada demo menerima parameter bertipe fs.FS, bukan tipe konkret, sehingga mengganti sumber berkas tidak menyentuh satu baris kode runner — manfaat interface kecil yang dibahas pada chapter Interface Kecil .

Kaedah Migration di Production #

Dua kaedah lain turut menjaga database tetap sehat. Pertama, buat migration idempoten: tulis CREATE TABLE IF NOT EXISTS atau ADD COLUMN IF NOT EXISTS sehingga penerapan ulang tidak menggagalkan startup aplikasi. Kedua, jangan mengubah data besar dalam satu statement — UPDATE jutaan baris akan mengunci tabel terlalu lama; pecah menjadi batch dan sebarkan lewat beberapa migration agar aplikasi tetap responsif.

Dengan skema yang terkelola lewat berkas bernomor, fokus kita berikutnya adalah kode aplikasi yang membaca dan menulis data. Chapter selanjutnya membahas repository pattern — cara memisahkan SQL dari logika bisnis agar keduanya bisa berkembang terpisah dan diuji terpisah.

Referensi #

Berkas Sumber

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

migration-demo.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"slices"
	"strconv"
	"strings"
	"testing/fstest"
)

// Runner adalah migration runner sederhana yang bekerja atas fs.FS.
// Field applied meniru tabel schema_migrations di database nyata.
type Runner struct {
	applied map[int]bool
}

func NewRunner() *Runner {
	return &Runner{applied: map[int]bool{}}
}

// pisahNama memecah "002_add_harga.up.sql" menjadi versi 2 dan label "add_harga".
func pisahNama(nama string) (int, string, error) {
	nama = strings.TrimSuffix(nama, ".sql")
	nama = strings.TrimSuffix(nama, ".up")
	nama = strings.TrimSuffix(nama, ".down")
	angka, label, ada := strings.Cut(nama, "_")
	if !ada {
		return 0, "", fmt.Errorf("nama migration tidak valid: %s", nama)
	}
	v, err := strconv.Atoi(angka)
	if err != nil {
		return 0, "", fmt.Errorf("nomor versi tidak valid: %s", nama)
	}
	return v, label, nil
}

// Up menerapkan semua migration .up.sql yang belum diterapkan, dari versi terkecil.
// "Eksekusi" SQL di sini hanya dicetak karena tidak ada database aktif.
func (r *Runner) Up(fsys fs.FS) error {
	names, err := daftarBerkas(fsys)
	if err != nil {
		return err
	}
	for _, name := range names {
		if !strings.HasSuffix(name, ".up.sql") {
			continue
		}
		v, label, err := pisahNama(name)
		if err != nil {
			return err
		}
		if r.applied[v] {
			fmt.Printf("lewati     %03d_%s (sudah diterapkan)\n", v, label)
			continue
		}
		sql, err := fs.ReadFile(fsys, name)
		if err != nil {
			return err
		}
		fmt.Printf("terapkan   %03d_%s: %s\n", v, label, sql)
		r.applied[v] = true
	}
	return nil
}

// Down membatalkan migration dengan versi terbesar, satu langkah saja.
func (r *Runner) Down(fsys fs.FS) error {
	terapan := r.terapannya()
	if len(terapan) == 0 {
		return errors.New("tidak ada migration untuk dibatalkan")
	}
	versi := terapan[len(terapan)-1]
	names, err := daftarBerkas(fsys)
	if err != nil {
		return err
	}
	for _, name := range names {
		if !strings.HasSuffix(name, ".down.sql") {
			continue
		}
		v, label, errN := pisahNama(name)
		if errN != nil {
			return errN
		}
		if v != versi {
			continue
		}
		sql, errR := fs.ReadFile(fsys, name)
		if errR != nil {
			return errR
		}
		fmt.Printf("batalkan   %03d_%s: %s\n", v, label, sql)
		delete(r.applied, v)
		return nil
	}
	return fmt.Errorf("berkas down untuk versi %d tidak ditemukan", versi)
}

// terapannya mengembalikan daftar versi yang sudah diterapkan, terurut naik.
func (r *Runner) terapannya() []int {
	hasil := make([]int, 0, len(r.applied))
	for v := range r.applied {
		hasil = append(hasil, v)
	}
	slices.Sort(hasil)
	return hasil
}

func daftarBerkas(fsys fs.FS) ([]string, error) {
	entries, err := fs.ReadDir(fsys, ".")
	if err != nil {
		return nil, err
	}
	names := make([]string, 0, len(entries))
	for _, e := range entries {
		names = append(names, e.Name())
	}
	slices.Sort(names)
	return names, nil
}

func main() {
	// MapFS menggantikan folder migrations/ di proyek nyata.
	// Di aplikasi sungguhan, gunakan embed.FS atau folder di disk.
	migrationsFS := fstest.MapFS{
		"001_create_items.up.sql":   &fstest.MapFile{Data: []byte("CREATE TABLE items (id SERIAL PRIMARY KEY, nama TEXT)")},
		"001_create_items.down.sql": &fstest.MapFile{Data: []byte("DROP TABLE items")},
		"002_add_harga.up.sql":      &fstest.MapFile{Data: []byte("ALTER TABLE items ADD COLUMN harga INTEGER")},
		"002_add_harga.down.sql":    &fstest.MapFile{Data: []byte("ALTER TABLE items DROP COLUMN harga")},
		"003_add_stok.up.sql":       &fstest.MapFile{Data: []byte("ALTER TABLE items ADD COLUMN stok INTEGER NOT NULL DEFAULT 0")},
		"003_add_stok.down.sql":     &fstest.MapFile{Data: []byte("ALTER TABLE items DROP COLUMN stok")},
	}

	runner := NewRunner()

	fmt.Println("== up pertama: semua migration baru diterapkan ==")
	if err := runner.Up(migrationsFS); err != nil {
		fmt.Println("galat:", err)
		return
	}
	fmt.Println("versi terapannya:", runner.terapannya())

	fmt.Println("== up kedua: migration lama dilewati ==")
	if err := runner.Up(migrationsFS); err != nil {
		fmt.Println("galat:", err)
		return
	}

	fmt.Println("== down satu langkah: versi terbesar dibatalkan ==")
	if err := runner.Down(migrationsFS); err != nil {
		fmt.Println("galat:", err)
		return
	}
	fmt.Println("versi terapannya:", runner.terapannya())
}