Database schema evolution is the backend’s most common source of breakage — add column, drop column, change type, change constraint, every time you need to plan “how to do this without downtime, how to rollback, how to migrate data”. This is the Flyway / Liquibase / Sqitch comparison, ER diagram maintenance, zero-downtime migration patterns, and seven strategies for big-table ALTERs.
Three mainstream tools
Tool
Style
Strength
Weakness
Flyway
SQL-only
simple, low learning curve, deep Java/Spring integration
complex logic needs Java migration
Liquibase
XML/YAML/SQL
rollback support, cross-DB abstraction
XML verbose, complex
Sqitch
Perl/SQL
pure SQL, verify scripts are re-runnable
smaller community
New projects pick Flyway — deepest Spring Boot integration, SQL-only keeps things simple.
participant "App v1" as V1 database "DB" as DB participant "App v2" as V2
== Phase 1: Expand (compatible with old format) == V1 -> DB : ① add new column phone (NULL allowed) note right of V1 Old V1 still runs (new column NULL, no impact) end note
== Phase 2: Migrate (data migration) == V1 -> DB : ② one-shot backfill old data\nUPDATE users SET phone = '+86-default'\nWHERE phone IS NULL V1 -> DB : ③ ALTER COLUMN phone SET NOT NULL note right of V1 DB constraint tightened but app doesn't use new column yet end note
== Phase 3: Deploy V2 (dual write) == V2 -> DB : ④ deploy new version, write old + new formats note right of V2 V2 dual-writes, ensures V1/V2 both work normally end note
== Phase 4: Migrate Read (V2 leads) == V2 -> DB : ⑤ deploy V2.1, read only from new column V1 -> DB : ⑥ old version retired
== Phase 5: Contract (cleanup) == V2 -> DB : ⑦ DROP COLUMN old column (if exists)
@enduml
Core idea: every phase is rollbackable — rolling back to the previous phase needs no data migration.
@startuml title Shadow Table Pattern (rename column)
participant "App" as App database "DB" as DB
== Step 1: add new column == App -> DB : ① ALTER TABLE users ADD COLUMN email_new VARCHAR(255) App -> DB : ② CREATE TRIGGER sync_email\nBEFORE INSERT OR UPDATE ON users\nFOR EACH ROW EXECUTE FUNCTION copy_email();
== Step 2: app layer dual write == note over App App writes both email and email_new, reads prefer email_new, old code reading email still works end note
== Step 3: data migration == App -> DB : ③ UPDATE users SET email_new = email\nWHERE email_new IS NULL;
== Step 4: switch app == note over App Deploy new version, read email_new only end note
== Step 5: cleanup == App -> DB : ④ DROP COLUMN email; App -> DB : ⑤ DROP TRIGGER sync_email;
PostgreSQL ALTER TABLE ... ALTER COLUMN type has two algorithms:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
-- ACCESS EXCLUSIVE lock (default) ALTER TABLE users ALTERCOLUMN phone TYPE VARCHAR(30); -- full table rewrite, locks table, big table (>1M rows) takes 10+ min
-- no lock (some type conversions supported) ALTER TABLE users ALTERCOLUMN phone TYPE VARCHAR(30) USING phone::VARCHAR(30); -- still full table rewrite
-- truly no lock: use pg_repack -- 1. add new column ALTER TABLE users ADDCOLUMN phone_new VARCHAR(30); -- 2. trigger sync -- 3. backfill UPDATE users SET phone_new = phone; -- 4. switch -- 5. drop old column
CI runs the test → new table added but ER diagram not updated → fail.
Field foot-guns
Flyway clean drops everything — never run in production. Use in local / test envs only.
Migrations are immutable — deployed V1__ cannot be modified, only add V2__ to fix.
ADD COLUMN NOT NULL without default — big table + NOT NULL = full table update, super long lock. Two steps: add nullable → UPDATE default → alter NOT NULL.
SERIAL type — PG 10+ uses BIGINT GENERATED ALWAYS AS IDENTITY, SERIAL still works but not recommended.
Liquibase XML indentation wrong — one tweak triggers syntax error, use YAML for compactness.
Multi-database abstraction trap — Liquibase BOOLEAN in MySQL becomes TINYINT(1), type differences cause more headaches. Direct SQL is more controllable.
Migration half-runs and fails — transaction not committed, migration table state inconsistent. Each migration is its own file + single transaction.
Decision tree
1 2 3 4 5 6 7 8 9 10 11
What do you need? ├─ New project → Flyway + SQL-only ├─ Java/Spring Boot → Flyway (deep integration) ├─ Need automatic rollback → Liquibase ├─ Multi-DB compat (Oracle/PG/MySQL) → Liquibase └─ Pure SQL + verify → Sqitch
Migration size? ├─ Small (<100k rows) → ALTER TABLE directly ├─ Large (>1M rows) → add new column → dual write → switch → drop old └─ Huge (>100M rows) → pt-online-schema-change / pg_repack / gh-ost
Minimum setup: Flyway + db/migration/V*.sql + CI runs flyway migrate + a gen_er.py script for ER sync. When changing big table schema, always think three questions first: can I batch? can I avoid downtime? can I roll back?
Title: Database schema evolution: Flyway/Liquibase/Sqitch + PlantUML ER diagrams