2026-08-24 · 10 min
Database Migration Rollback yang Aman: Strategi Undo Tanpa Merusak Data Production
Database migration yang berjalan mulus itu mudah. Yang susah adalah ketika migration sudah jalan di production, data sudah masuk, lalu ada bug kritis dan Anda harus mundur. Saya pernah menghadapi situasi ini di klien Jakarta — tiga kali dalam dua tahun — dan setiap kali caranya berbeda tergantung seberapa jauh migration sudah berjalan dan seberapa banyak data sudah berubah.
Masalah dengan asumsi “tinggal down()”
Banyak developer berpikir rollback migration itu sederhana: jalankan migrate down atau balik file SQL, selesai. Asumsi ini berbahaya.
Pertimbangkan skenario yang terlihat innocent: Anda menambah kolom verified_at TIMESTAMPTZ di tabel users, lalu mengisi semua row yang sudah ada dengan NOW(), dan men-deploy application baru yang menulis ke kolom tersebut. Satu jam kemudian Anda sadar ada bug di application baru.
-- up migration
ALTER TABLE users ADD COLUMN verified_at TIMESTAMPTZ;
UPDATE users SET verified_at = NOW() WHERE verified_at IS NULL;
ALTER TABLE users ALTER COLUMN verified_at SET NOT NULL;
Apa yang terjadi kalau Anda jalankan down()?
-- down migration yang "naif"
ALTER TABLE users ALTER COLUMN verified_at DROP NOT NULL;
ALTER TABLE users DROP COLUMN verified_at;
Data verified_at yang ditulis application selama satu jam — termasuk user baru yang baru daftar dan langsung mendapat nilai verified_at — hilang selamanya. ALTER TABLE ... DROP COLUMN tidak bisa di-undo. Dan yang lebih parah: application versi lama yang Anda rollback ke sana tidak pernah tahu bahwa data itu ada, jadi tidak ada yang protes. Bug diam-diam.
Tiga strategi rollback, tiga situasi berbeda
Pengalaman saya menunjukkan tidak ada satu strategi rollback yang cocok untuk semua situasi. Ada tiga pendekatan yang saya pakai, dipilih berdasarkan seberapa jauh perubahan sudah berjalan.
Strategi 1: Rollback DDL murni (window sempit, additive only)
Ini satu-satunya situasi di mana down() aman: migration bersifat additive murni dan dilakukan dalam window sangat sempit sebelum application di-deploy ke versi baru.
Contoh aman untuk DDL rollback:
-- Migration additive: tambah tabel baru
-- up.sql
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
action TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
-- down.sql (aman karena tabel baru, belum ada data penting)
DROP INDEX idx_audit_logs_entity;
DROP TABLE audit_logs;
Kondisi yang harus terpenuhi: tabel baru belum punya data yang penting, application versi baru belum di-deploy, dan rollback dilakukan dalam hitungan menit setelah migration. Begitu satu kondisi ini tidak terpenuhi, pindah ke strategi lain.
Strategi 2: Expand-contract untuk perubahan kolom
Ini strategi yang paling sering saya rekomendasikan untuk perubahan kolom yang sedang dipakai application. Idenya: pisahkan perubahan menjadi fase yang bisa di-rollback secara independen.
Skenario: rename kolom phone menjadi phone_number.
Fase 1 — Expand: tambah kolom baru, jangan sentuh yang lama.
-- Migration 001: tambah kolom baru (backward compatible)
ALTER TABLE users ADD COLUMN phone_number TEXT;
-- Sync data dari kolom lama ke baru
UPDATE users SET phone_number = phone WHERE phone IS NOT NULL;
Di fase ini, deploy application yang menulis ke kedua kolom sekaligus:
// Application versi transisi: tulis ke keduanya
await db.execute(sql`
UPDATE users
SET phone = ${newPhone}, phone_number = ${newPhone}
WHERE id = ${userId}
`);
Kalau ada masalah di fase ini, rollback trivial: ALTER TABLE users DROP COLUMN phone_number. Tidak ada data yang hilang karena kolom lama masih utuh.
Fase 2 — Contract: setelah yakin kolom baru sudah terisi dan application sudah tidak pakai kolom lama.
-- Migration 002: hapus kolom lama (hanya setelah yakin)
ALTER TABLE users DROP COLUMN phone;
Rollback fase 2 lebih kompleks karena kolom lama sudah hilang, tapi ini dilakukan setelah periode stabilisasi (biasanya 1-2 sprint), bukan dalam keadaan darurat.
Strategi 3: Snapshot + restore untuk migrasi data massal
Untuk migration yang melibatkan UPDATE massal atau transformasi data yang tidak bisa di-undo dengan DDL, saya selalu buat backup tabel terlebih dahulu.
-- Sebelum migration data massal
CREATE TABLE orders_backup_20260824 AS
SELECT * FROM orders;
-- Verifikasi snapshot
SELECT COUNT(*) FROM orders_backup_20260824;
-- Harus sama dengan: SELECT COUNT(*) FROM orders
Baru jalankan migration:
-- Migration: normalisasi kolom amount dari IDR ke satuan sen
UPDATE orders
SET amount_cents = ROUND(amount_idr * 100)::BIGINT
WHERE amount_cents IS NULL;
Kalau ada masalah, rollback bukan dengan down() tapi dengan restore selektif:
-- Rollback selektif: kembalikan data dari snapshot
BEGIN;
-- Reset kolom yang diubah
UPDATE orders o
SET amount_cents = NULL
FROM orders_backup_20260824 b
WHERE o.id = b.id
AND o.amount_cents IS NOT NULL
AND b.amount_cents IS NULL; -- hanya row yang diubah migration
COMMIT;
Pendekatan ini jauh lebih aman dari pg_restore full karena:
- Tidak menimpa data yang masuk setelah migration
- Bisa dilakukan secara transaksional per batch
- Tidak perlu downtime
Tabel backup orders_backup_20260824 saya drop setelah 7 hari kalau tidak ada masalah.
Praktik wajib: version lock dan schema snapshot
Selain strategi per-situasi, ada dua praktik yang saya terapkan di semua project production.
Version lock pada migration tools. Flyway dan Liquibase punya checksums untuk mendeteksi kalau file migration diubah setelah dijalankan. Jangan pernah edit file migration yang sudah di-apply — buat migration baru untuk koreksi. Ini bukan soal tools, tapi soal audit trail yang jelas.
Schema snapshot sebelum migration besar. Sebelum migration yang melibatkan lebih dari satu tabel atau ada data transformation, saya selalu dump schema + sample data:
# Snapshot schema sebelum migration
pg_dump \
--schema-only \
--no-owner \
--no-privileges \
-f "schema_backup_$(date +%Y%m%d_%H%M%S).sql" \
$DATABASE_URL
# Snapshot tabel-tabel yang akan berubah
pg_dump \
--table=users \
--table=orders \
--data-only \
-f "data_backup_$(date +%Y%m%d_%H%M%S).sql" \
$DATABASE_URL
Snapshot ini bukan untuk restore full — itu terlalu lambat dan tidak aman untuk production aktif. Fungsinya sebagai referensi: kalau ada row yang bermasalah pasca-migration, Anda tahu nilai aslinya.
Menulis SQL rollback yang eksplisit
Satu pelajaran mahal dari pengalaman: jangan percaya pada auto-generated rollback. Tools seperti Flyway (edisi Community) atau beberapa ORM menghasilkan rollback otomatis yang sering salah untuk kasus non-trivial.
Saya memaksa tim untuk selalu menulis SQL rollback eksplisit di file terpisah, bahkan kalau tidak dipakai:
migrations/
V20260824__add_verified_at_column.sql # up
U20260824__add_verified_at_column.sql # undo (Flyway Teams)
Isi U20260824__add_verified_at_column.sql bukan generated — ditulis manual oleh developer yang sama yang menulis migration up-nya, di-review bersama. Proses ini memaksa developer berpikir: “kalau ini harus di-rollback saat data sudah masuk, apa yang terjadi dengan data itu?”
Pertanyaan itu sendiri sudah memberi nilai. Kalau jawabannya “data akan hilang dan tidak bisa dikembalikan”, itu sinyal bahwa strategi expand-contract atau snapshot lebih tepat.
Trade-off yang perlu diakui
Expand-contract menambah kompleksitas deploy. Alih-alih satu migration, Anda punya tiga fase dengan rentang waktu berhari-hari di antaranya. Application harus bisa berjalan di atas dua versi skema sekaligus selama periode transisi — ini butuh disiplin di kode application, khususnya kalau ORM Anda generate query dari skema secara otomatis.
Snapshot tabel juga punya ongkos storage yang tidak kecil kalau tabel berukuran besar. Tabel orders production kami di salah satu klien mencapai 40 GB — snapshot sebelum migration artinya butuh 40 GB ruang tambahan. Ini harus direncanakan, bukan dadakan.
Dan untuk strategi mana pun: rollback yang berhasil butuh latihan. Saya tidak percaya tim yang belum pernah mencoba prosedur rollback di staging. Proses yang tidak pernah dilatih tidak akan berjalan mulus saat kondisi darurat jam 2 pagi.
Verdict
Pilih strategi berdasarkan sifat perubahan, bukan kebiasaan:
- Additive murni, belum di-deploy:
down()aman, gunakan. - Perubahan kolom yang sedang aktif: expand-contract, pisah fase, rollback per fase.
- Transformasi data massal: snapshot tabel dulu, rollback dengan UPDATE selektif dari snapshot, bukan pg_restore.
Satu aturan yang tidak pernah saya langgar: jangan drop kolom atau tabel dalam kondisi darurat tanpa backup yang terverifikasi. DROP COLUMN di production saat aplikasi aktif itu one-way door. Semua strategi di atas pada dasarnya adalah cara untuk memastikan Anda tidak pernah terpaksa membuka pintu itu tanpa bisa kembali.
Ditulis oleh Reza Pradipta