Database Migration Tool: Golang vs Rust

by Marcus Chen
Database Migration Tool: Golang vs Rust

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 sqlx dan mau konsistensi di satu ekosistem (meski sqlx punya 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:

  1. Integrate migration ke CI/CD pipeline — jalanin migrate up otomatis sebelum deploy
  2. Buat script yang check apakah ada pending migration sebelum aplikasi start
  3. 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.