Master schema migration with Flyway: versioned and repeatable migrations, lifecycle commands (migrate/validate/info/repair), and zero-downtime migration patterns (expand-contract). Compare with Liquibase.
📚 Background
Why Schema Migration Tools?
Manual ALTER TABLE scripts don't scale:
Which version is production on? Dev? Staging?
Did this migration already run?
How to rollback safely?
Migration tools solve: version tracking, ordering, idempotency, and team coordination.
Flyway Concepts
File Type
Naming
Purpose
Versioned
V1__description.sql
Runs once, in order
Repeatable
R__description.sql
Runs when checksum changes
Undo
U1__description.sql
Rollback (Flyway Teams)
Flyway Schema History Table
Zero-Downtime Migration: Expand-Contract
Step 1: Start PostgreSQL & Install Flyway
📸 Verified Output:
Step 2: Create Migration Scripts
📸 Verified Output:
Step 3: Run Flyway Migrations
📸 Verified Output:
Step 4: Validate & Query Current State
📸 Verified Output:
Step 5: Zero-Downtime Migration — Expand-Contract
📸 Verified Output:
💡 Zero-downtime key: Never ALTER TABLE RENAME COLUMN or DROP COLUMN while old code still runs. Use expand-contract: add → both write → remove old.
Step 6: Flyway Repair & Failed Migrations
📸 Verified Output:
Step 7: Liquibase Comparison
📸 Verified Output:
Step 8: Capstone — Migration Best Practices
📸 Verified Output:
Summary
Concept
Key Takeaway
Versioned migration
V1__name.sql — runs once, tracked by version number
Repeatable migration
R__name.sql — re-runs when file checksum changes
flyway migrate
Apply all pending migrations in version order
flyway validate
Check that applied migrations match files on disk
flyway info
Show status of all migrations (pending/applied/failed)
flyway repair
Remove failed entries from history; fix checksums
Expand-Contract
Add nullable column → backfill → add constraint → drop old
CREATE INDEX CONCURRENTLY
Build index without blocking writes
NOT VALID constraint
Add FK without scanning existing rows
💡 Architect's rule: Treat migration scripts like production code — version controlled, reviewed, tested in staging before production. Never edit a migration that has already run.
Phase 1 (Expand): Add new_col (nullable), deploy code that writes to both
Phase 2 (Migrate): Backfill existing rows
Phase 3 (Contract): Remove old_col after all reads use new_col
# Apply migrations using psql directly (simulating what flyway does)
# In production: flyway -url=jdbc:postgresql://... -user=postgres -password=rootpass migrate
docker exec -i pg-lab psql -U postgres << 'SQL'
CREATE DATABASE appdb;
SQL
for sql_file in /tmp/flyway-demo/sql/V1__init_schema.sql \
/tmp/flyway-demo/sql/V2__add_user_roles.sql \
/tmp/flyway-demo/sql/V3__add_audit_log.sql \
/tmp/flyway-demo/sql/V4__add_fullname.sql \
/tmp/flyway-demo/sql/V5__seed_data.sql \
/tmp/flyway-demo/sql/R__user_views.sql; do
echo "Applying: $(basename $sql_file)"
docker cp "$sql_file" pg-lab:/tmp/
docker exec pg-lab psql -U postgres -d appdb -f "/tmp/$(basename $sql_file)" -q
done
# Simulate flyway_schema_history table
docker exec -i pg-lab psql -U postgres -d appdb << 'SQL'
CREATE TABLE IF NOT EXISTS flyway_schema_history (
installed_rank INT NOT NULL,
version VARCHAR(50),
description VARCHAR(200),
type VARCHAR(20),
script VARCHAR(1000),
checksum INT,
installed_by VARCHAR(100),
installed_on TIMESTAMP DEFAULT NOW(),
execution_time INT,
success BOOLEAN
);
INSERT INTO flyway_schema_history (installed_rank, version, description, type, script, success) VALUES
(1, '1', 'init schema', 'SQL', 'V1__init_schema.sql', TRUE),
(2, '2', 'add user roles', 'SQL', 'V2__add_user_roles.sql', TRUE),
(3, '3', 'add audit log', 'SQL', 'V3__add_audit_log.sql', TRUE),
(4, '4', 'add fullname', 'SQL', 'V4__add_fullname.sql', TRUE),
(5, '5', 'seed data', 'SQL', 'V5__seed_data.sql', TRUE),
(6, NULL, 'user views', 'SQL', 'R__user_views.sql', TRUE);
-- Flyway info output
SELECT installed_rank AS rank,
COALESCE(version, 'Repeatable') AS version,
description,
type,
script,
success,
installed_on::TIME AS applied_at
FROM flyway_schema_history ORDER BY installed_rank;
SQL
rank | version | description | type | script | success | applied_at
------+------------+----------------+------+------------------------+---------+-----------
1 | 1 | init schema | SQL | V1__init_schema.sql | t | 10:30:01
2 | 2 | add user roles | SQL | V2__add_user_roles.sql | t | 10:30:01
3 | 3 | add audit log | SQL | V3__add_audit_log.sql | t | 10:30:01
4 | 4 | add fullname | SQL | V4__add_fullname.sql | t | 10:30:01
5 | 5 | seed data | SQL | V5__seed_data.sql | t | 10:30:01
6 | Repeatable | user views | SQL | R__user_views.sql | t | 10:30:01
docker exec -i pg-lab psql -U postgres -d appdb << 'SQL'
-- Verify schema after all migrations
\dt
-- Query the view (created by repeatable migration)
SELECT * FROM v_user_summary ORDER BY id;
-- Check table structure (result of incremental migrations)
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'users'
ORDER BY ordinal_position;
SQL
List of relations
Schema | Name | Type
--------+--------------------+-------
public | audit_log | table
public | flyway_schema_history | table
public | roles | table
public | user_roles | table
public | users | table
id | username | email | full_name | is_active | roles
----+------------+--------------------+--------------+-----------+-----------
1 | alice | [email protected] | Alice Chen | t | {user}
2 | bob | [email protected] | Bob Smith | t | {user}
3 | admin_user | [email protected] | System Admin | t | {admin}
cat > /tmp/flyway-demo/sql/V6__rename_email_expand.sql << 'SQL'
-- PHASE 1: EXPAND
-- Scenario: rename 'email' to 'email_address' without downtime
-- Step 1: Add new column (nullable = no downtime)
ALTER TABLE users ADD COLUMN email_address VARCHAR(100);
-- Step 2: Copy existing data
UPDATE users SET email_address = email WHERE email_address IS NULL;
-- Step 3: Add NOT NULL constraint (after backfill)
ALTER TABLE users ALTER COLUMN email_address SET NOT NULL;
-- Step 4: Application deploys code that writes to BOTH columns
-- (During this phase, both old and new code can work)
COMMENT ON COLUMN users.email_address IS 'New canonical email column';
COMMENT ON COLUMN users.email IS 'DEPRECATED: use email_address';
SQL
docker cp /tmp/flyway-demo/sql/V6__rename_email_expand.sql pg-lab:/tmp/
docker exec pg-lab psql -U postgres -d appdb -f /tmp/V6__rename_email_expand.sql -q
# V7: Contract phase (run AFTER all app instances use new column)
cat > /tmp/flyway-demo/sql/V7__rename_email_contract.sql << 'SQL'
-- PHASE 3: CONTRACT
-- Old code no longer in production, safe to drop old column
ALTER TABLE users DROP COLUMN email;
-- Rename to make it permanent (or keep email_address)
-- ALTER TABLE users RENAME COLUMN email_address TO email; -- optional
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' ORDER BY ordinal_position;
SQL
docker cp /tmp/flyway-demo/sql/V7__rename_email_contract.sql pg-lab:/tmp/
docker exec pg-lab psql -U postgres -d appdb -f /tmp/V7__rename_email_contract.sql
docker exec -i pg-lab psql -U postgres -d appdb -c "
SELECT username, email_address FROM users;
"
column_name
---------------
id
username
full_name
phone
is_active
created_at
email_address
username | email_address
------------+--------------------
alice | [email protected] bob | [email protected] admin_user | [email protected]
docker exec -i pg-lab psql -U postgres -d appdb << 'SQL'
-- Simulate a FAILED migration (checksum mismatch or partial apply)
INSERT INTO flyway_schema_history (installed_rank, version, description, type, script, success)
VALUES (8, '8', 'broken migration', 'SQL', 'V8__broken.sql', FALSE);
-- flyway info would show: FAILED
SELECT installed_rank, version, description, success
FROM flyway_schema_history ORDER BY installed_rank;
-- flyway repair removes failed entry from history (so it can be re-run)
DELETE FROM flyway_schema_history WHERE success = FALSE;
-- After repair, fix the script and re-run flyway migrate
-- flyway repair also fixes checksum mismatches by recalculating
SELECT 'After repair: ' || COUNT(*) || ' successful migrations' AS status
FROM flyway_schema_history WHERE success = TRUE;
SQL
installed_rank | version | description | success
----------------+---------+-------------------+---------
1 | 1 | init schema | t
...
8 | 8 | broken migration | f
After repair:
status
---------------------------------------
After repair: 7 successful migrations
Flyway vs Liquibase Comparison
======================================================================
Feature Flyway Liquibase
----------------------------------------------------------------------
Migration Format SQL files (V1__.sql) or Java XML, YAML, JSON, or SQL changesets
Rollback Undo migrations (Teams edition) Built-in rollback for most operations
Best For SQL-first teams, fast setup Multi-DB support, rollback needs
cat > /tmp/migration_best_practices.py << 'EOF'
"""
Database migration best practices and anti-patterns.
"""
best_practices = [
{
"practice": "Always add columns as nullable first",
"code": "ALTER TABLE t ADD COLUMN col VARCHAR(100);",
"why": "Adding NOT NULL on large tables causes full table lock in PostgreSQL < 11"
},
{
"practice": "Use NOT VALID for new constraints",
"code": "ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (id) REFERENCES o(id) NOT VALID;\nALTER TABLE t VALIDATE CONSTRAINT fk;",
"why": "NOT VALID adds constraint without scanning existing rows (no lock), then validate separately"
},
{
"practice": "Create indexes CONCURRENTLY",
"code": "CREATE INDEX CONCURRENTLY idx_users_email ON users(email);",
"why": "Non-concurrent index creation locks writes; CONCURRENTLY builds in background"
},
{
"practice": "Never modify existing migration scripts",
"code": "# Bad: edit V3__migration.sql after running\n# Good: create V4__fix_migration.sql",
"why": "Flyway checksums detect changes and raise error; history becomes inconsistent"
},
{
"practice": "Make migrations idempotent where possible",
"code": "CREATE TABLE IF NOT EXISTS t (...);\nCREATE INDEX IF NOT EXISTS idx ON t(col);",
"why": "Safe to re-run after partial failure; flyway repair + re-migrate works"
},
{
"practice": "Separate DDL from DML in migrations",
"code": "V5__add_column.sql -- DDL only\nV6__backfill_data.sql -- DML only",
"why": "DDL often auto-commits; mixing with DML makes rollback harder"
},
{
"practice": "Backfill in batches, not full table",
"code": "DO $$ DECLARE i INT; BEGIN FOR i IN 1..1000 LOOP UPDATE users SET col=val WHERE id BETWEEN (i-1)*1000 AND i*1000; COMMIT; END LOOP; END $$;",
"why": "Full table UPDATE holds lock; batching minimizes contention"
},
{
"practice": "Test rollback plan before production",
"code": "# In staging: apply migration, verify, then rollback, verify rollback",
"why": "70% of incidents involve failed rollbacks that weren't tested"
},
]
anti_patterns = [
"RENAME COLUMN with live traffic",
"DROP COLUMN without expand-contract",
"Adding NOT NULL without default on large table",
"Creating unique index non-concurrently on large table",
"Mixing schema and data changes in one migration",
"Using ORM auto-migrate in production",
]
print("Migration Best Practices")
print("="*60)
for i, bp in enumerate(best_practices, 1):
print(f"\n{i}. {bp['practice']}")
print(f" Why: {bp['why']}")
print("\n\nAnti-Patterns (NEVER DO):")
print("-"*40)
for ap in anti_patterns:
print(f" ✗ {ap}")
EOF
python3 /tmp/migration_best_practices.py
# Cleanup
docker rm -f pg-lab 2>/dev/null
Migration Best Practices
============================================================
1. Always add columns as nullable first
Why: Adding NOT NULL on large tables causes full table lock in PostgreSQL < 11
2. Use NOT VALID for new constraints
Why: NOT VALID adds constraint without scanning existing rows (no lock)
3. Create indexes CONCURRENTLY
Why: Non-concurrent index creation locks writes
Anti-Patterns (NEVER DO):
----------------------------------------
✗ RENAME COLUMN with live traffic
✗ DROP COLUMN without expand-contract
✗ Adding NOT NULL without default on large table