Masalah Klasik: Migration Berantakan di Production
Kamu pernah nggak, lagi deploy fitur baru, terus tiba-tiba production database-nya nggak sync sama schema terbaru? Kolom baru yang kamu tambahin di staging nggak ada di production, dan endpoint langsung 500. Itu sakit.
Ini bukan masalah skill — ini masalah tooling. Kalau kamu nulis migration pakai raw SQL yang dijalanin manual, atau worse, nulis script bash sendiri, cepat atau lambat kamu bakal ketemu masalah ini.
Dua ekosistem yang lagi banyak dipakai buat backend sekarang — Golang dan Rust — punya pendekatan berbeda soal database migration tool. Artikel ini bakal ngebandingin keduanya secara teknis, lengkap dengan kode yang bisa langsung kamu jalanin.
Golang: golang-migrate, Tool yang Udah Battle-Tested
Kalau kamu pakai Golang, pilihan paling populer adalah golang-migrate. Tool ini udah mature, support banyak database (PostgreSQL, MySQL, SQLite, bahkan CockroachDB), dan bisa dipakai lewat CLI maupun sebagai library di dalam kode kamu.
Install
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Atau kalau mau pakai sebagai library:
go get github.com/golang-migrate/migrate/v4
Struktur File Migration
golang-migrate pakai konvensi penamaan file yang ketat:
migrations/
000001_create_users_table.up.sql
000001_create_users_table.down.sql
000002_add_email_index.up.sql
000002_add_email_index.down.sql
Setiap migration punya file .up.sql (apply) dan .down.sql (rollback). Simpel tapi efektif.
Contoh File Migration
-- 000001_create_users_table.up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;
Jalanin Migration Lewat CLI
migrate -path ./migrations -database "postgres://user:pass@localhost:5432/mydb?sslmode=disable" up
Buat rollback:
migrate -path ./migrations -database "postgres://user:pass@localhost:5432/mydb?sslmode=disable" down 1
Embed Migration di Dalam Kode Go
Ini yang bikin golang-migrate powerful — kamu bisa jalanin migration otomatis waktu aplikasi start:
package main
import (
"database/sql"
"embed"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/lib/pq"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func runMigrations(db *sql.DB) error {
sourceDriver, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return err
}
dbDriver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithInstance("iofs", sourceDriver, "postgres", dbDriver)
if err != nil {
return err
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}
log.Println("Migration selesai")
return nil
}
Pakai embed.FS di sini supaya binary kamu sudah include file SQL-nya — nggak perlu bawa folder migrations/ terpisah waktu deploy.
Gotcha golang-migrate yang Pernah Gue Kena
Kalau migration kamu gagal di tengah jalan (misalnya syntax error di SQL), golang-migrate bakal mark versi itu sebagai "dirty". Waktu kamu coba jalanin lagi, dia bakal error:
error: Dirty database version 2. Fix and force version.
Solusinya:
migrate -path ./migrations -database "$DATABASE_URL" force 1
Ini bakal reset dirty flag ke versi sebelumnya. Tapi hati-hati — pastiin kamu udah beneran rollback perubahan database-nya secara manual dulu sebelum force, karena tool ini nggak otomatis undo SQL yang udah keeksekusi sebagian.
Rust: Refinery, Migration Tool yang Type-Safe
Di ekosistem Rust, ada beberapa pilihan — sqlx punya built-in migration, tapi kalau kamu mau tool yang lebih dedicated dan bisa dipakai dengan berbagai database driver, Refinery adalah pilihan solid.
Refinery compile migration SQL langsung ke dalam binary kamu lewat macro. Artinya, nggak ada runtime file lookup — semua sudah di-embed waktu compile time.
Setup di Cargo.toml
[dependencies]
refinery = { version = "0.8", features = ["tokio-postgres"] }
tokio-postgres = "0.7"
tokio = { version = "1", features = ["full"] }
Struktur File Migration
Refinery support dua format penamaan:
src/migrations/
V1__create_users_table.sql
V2__add_email_index.sql
Perhatiin: dua underscore (__) antara versi dan nama. Ini mandatory — kalau salah format, Refinery bakal panic waktu compile.
Contoh File Migration
-- V1__create_users_table.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
Embed dan Jalanin Migration di Rust
use refinery::embed_migrations;
// Macro ini scan folder migrations/ dan embed semua SQL file
embed_migrations!("src/migrations");
#[tokio::main]
async fn main() {
let (mut client, connection) = tokio_postgres::connect(
"host=localhost user=postgres password=secret dbname=mydb",
tokio_postgres::NoTls,
)
.await
.expect("Gagal konek ke database");
// Spawn connection handler
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("Connection error: {}", e);
}
});
// Jalanin semua migration yang belum diapply
migrations::runner()
.run_async(&mut client)
.await
.expect("Migration gagal");
println!("Migration selesai!");
}
Refinery track migration yang sudah dijalanin di tabel refinery_schema_history di database kamu — mirip cara Flyway kerja kalau kamu pernah pakai di Java.
Gotcha Refinery yang Perlu Kamu Tahu
Refinery nggak support rollback secara native. Ini keputusan desain yang disengaja — filosofinya adalah migration harus forward-only. Kalau kamu butuh rollback, kamu harus bikin migration baru yang undo perubahan sebelumnya.
Selain itu, karena migration di-embed waktu compile time lewat macro, setiap kali kamu tambah file migration baru, kamu harus compile ulang. Ini trade-off: kamu dapat binary yang self-contained, tapi nggak bisa inject migration dari luar tanpa recompile.
Perbandingan Head-to-Head
Kemudahan Setup
golang-migrate lebih mudah untuk mulai — install CLI, tulis SQL, jalanin. Refinery butuh sedikit setup awal karena kamu perlu configure Cargo features sesuai database driver yang dipakai.
Rollback Support
| Fitur | golang-migrate | Refinery |
|---|---|---|
| Up migration | ✅ | ✅ |
| Down/rollback | ✅ | ❌ (forward-only) |
| Embed di binary | ✅ (via embed.FS) | ✅ (compile-time macro) |
| CLI tool | ✅ | ❌ (library only) |
| Dirty state handling | Manual force | Otomatis error |
Performa
Jujur, buat migration tool, performa bukan faktor utama — kamu jalanin ini sekali waktu deploy, bukan di hot path. Tapi kalau kamu penasaran: keduanya basically instant untuk jumlah migration yang wajar (ratusan file).
Ekosistem
golang-migrate lebih mature dan punya komunitas lebih besar. Support database-nya lebih banyak, termasuk database eksotis seperti Cassandra dan ClickHouse. Refinery lebih terbatas tapi cukup untuk use case umum (PostgreSQL, MySQL, SQLite).
Kapan Pilih Yang Mana?
Pilih golang-migrate kalau:
- Kamu butuh rollback yang proper
- Tim kamu lebih nyaman dengan workflow CLI
- Kamu pakai database selain PostgreSQL/MySQL/SQLite
- Kamu mau fleksibilitas inject migration dari file system (berguna untuk multi-tenant setup)
Pilih Refinery kalau:
- Kamu sudah all-in di Rust dan mau migration yang fully embedded
- Kamu oke dengan forward-only migration philosophy
- Kamu mau binary yang benar-benar self-contained tanpa file eksternal
- Kamu pakai
sqlxdan mau konsistensi di satu ekosistem (meskisqlxpunya migration built-in sendiri)
Alternatif: sqlx Migration (Bonus)
Kalau kamu pakai sqlx di Rust, dia punya built-in migration yang lebih simpel dari Refinery:
cargo install sqlx-cli
sqlx migrate add create_users_table
sqlx migrate run
File migration-nya disimpan di folder migrations/ dan formatnya mirip golang-migrate — ada up dan down. Ini mungkin pilihan paling pragmatis kalau kamu udah pakai sqlx.
-- migrations/20240101000001_create_users_table.up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- migrations/20240101000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;
Yang Gue Pakai Sekarang
Buat project Golang, gue default ke golang-migrate dengan embed.FS. Alasannya simpel: rollback support itu penting waktu hotfix di production, dan workflow CLI-nya udah familiar buat semua orang di tim.
Buat project Rust, gue pakai sqlx migration karena gue udah pakai sqlx sebagai query builder — nggak masuk akal tambah dependency baru cuma untuk migration.
Langkah lanjutan yang bisa kamu coba:
- Integrate migration ke CI/CD pipeline — jalanin
migrate upotomatis sebelum deploy - Buat script yang check apakah ada pending migration sebelum aplikasi start
- Kalau pakai Docker, pertimbangin jalanin migration di init container terpisah supaya nggak blocking main app container, mirip seperti jasa install dan setting WordPress siap pakai yang mengotomasi setup kompleks
Database migration yang proper itu bukan overkill — itu fondasi. Schema yang nggak tertrack dengan baik bakal jadi technical debt yang nyata waktu tim kamu mulai scale.