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:
migrations/
├── 001_create_items.up.sql
├── 001_create_items.down.sql
├── 002_add_harga.up.sql
└── 002_add_harga.down.sqlIsi pasangan pertama pada contoh di atas:
-- 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:
| Tool | Ciri khas | Cocok untuk |
|---|---|---|
| golang-migrate | CLI + library, berkas .up.sql/.down.sql, banyak driver | Proyek yang ingin kontrol penuh lewat SQL murni |
| goose | Komentar directive -- +goose Up di dalam berkas SQL, alternatif fungsi Go | Migration SQL yang juga memuat seeding data |
| atlas | Skema deklaratif (HCL) yang di-diff untuk menghasilkan migration | Tim 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:
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:
go run migration-demo.goOutput yang Dihasilkan:
== 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:
- Baris 15-17 (
type Runner): menyimpanapplied map[int]bool— tiruan tabelschema_migrationsdi memori. - Baris 46-48 (filter
.up.sql): hanya berkasupyang diproses saat naik; berkasdowndiabaikan agar tidak ikut diterapkan. - Baris 54-57 (cek
applied): versi yang sudah tercatat dilewati — penerapan ulang tidak terjadi. - Baris 71-74 (Down):
terapannya()sudah terurut naik, sehingga versi terbesar tinggal diambil dari elemen terakhir sebelum mencari berkasdownyang cocok. - Baris 127-135 (MapFS):
fstest.MapFSmenyediakan keenam berkas SQL dari memori. Ganti argumenUpdanDowndenganembed.FSatauos.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:
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 #
- Package io/fs
— kontrak
fs.FSyang membuat runner independen dari sumber berkas. - Package testing/fstest
—
MapFSuntuk menguji kode yang membaca berkas. - Package embed
— directive
//go:embeduntuk memasukkan migration ke binary.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
migration-demo.go
Unduh berkaspackage 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())
}