- Published on
Flyway & Database Migration Complete Guide 2025: Schema Versioning, ORM Integration, Zero-Downtime Deployment
- Authors

- Name
- Youngju Kim
- @fjvbn20031
1. Why Database Migration Matters
The "Works on My Machine" Problem with DB Schemas
Every team version-controls their code with Git, but how many apply the same discipline to database schemas? The answer is often a painful no.
Developer A: "I added a phone column to users table"
Developer B: "Huh? It's not on my local"
Developer A: "I shared it on Slack..."
Developer C: "What about staging? Production?"
(3 hours later, production incident)
This scenario repeats across countless teams. When code and schema get out of sync, deployments break and rollbacks become nightmares.
Manual ALTER TABLE Horror Stories
-- Friday 6 PM, directly on production...
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE orders MODIFY COLUMN status ENUM('pending','processing','shipped','delivered','cancelled');
-- Accidentally forgot WHERE clause...
UPDATE users SET role = 'admin';
-- Ctrl+C... too late
Problems with manual management:
- No audit trail of who changed what and when
- Schemas diverge across environments (local/staging/production)
- No rollback procedure
- No code review for schema changes
- Execution order is not guaranteed
Version Control for Databases: Same Principle as Git for Code
Just as we use Git for code, databases need version control too.
Git for Code DB Migration for Schemas
------------------ ---------------------------
commit history → migration history
branch/merge → migration branching
code review (PR) → migration review
rollback (revert) → undo migration
CI/CD pipeline → migration in pipeline
Core principles:
- All schema changes via migration files - no direct SQL execution
- Migrations version-controlled with code - included in Git repository
- Automated execution - run through CI/CD pipelines
- Idempotency - running the same migration multiple times yields the same result
- Order guarantee - migrations execute in defined sequence
2. Migration Approaches Comparison
Tool Comparison Matrix
| Tool | Approach | Language/Ecosystem | Strengths | Weaknesses |
|---|---|---|---|---|
| Flyway | Version-based SQL | Java/CLI | Simple, SQL-native | Many paid features |
| Liquibase | Changeset XML/YAML/SQL | Java/CLI | Rollback, diff | Complex config |
| Prisma Migrate | Declarative schema | TypeScript | Type-safe, Shadow DB | Prisma-locked |
| TypeORM | Code-based migration | TypeScript | Auto-generate from entity | TypeORM-locked |
| Alembic | Version-based Python | Python | SQLAlchemy integration | Python only |
| Django Migrations | Auto-detect | Python | Full ORM integration | Django only |
| Knex.js | Code-based | JavaScript | Lightweight | Limited features |
| golang-migrate | Version-based SQL | Go/CLI | Lightweight, multi-DB | Minimal features |
Selection Criteria
What is your project's tech stack?
├── Java/Spring → Flyway or Liquibase
│ ├── Prefer simplicity → Flyway
│ └── Need rollback/diff → Liquibase
├── TypeScript/Node.js
│ ├── Using Prisma → Prisma Migrate
│ ├── Using TypeORM → TypeORM Migration
│ └── Lightweight → Knex.js
├── Python
│ ├── Django → Django Migrations
│ └── Flask/FastAPI → Alembic
├── Go → golang-migrate
└── Language-agnostic, raw SQL → Flyway CLI
3. Flyway Deep Dive
3.1 Introduction to Flyway
Flyway is the most widely used database migration tool. Its core philosophy is simplicity: version-numbered SQL files executed in order.
flyway_migrations/
├── V1__Create_users_table.sql
├── V2__Create_orders_table.sql
├── V3__Add_email_column.sql
├── V4__Create_products_table.sql
├── R__Create_views.sql # Repeatable
└── U3__Add_email_column.sql # Undo (Teams)
3.2 Installation
CLI Installation (macOS)
# Homebrew
brew install flyway
# Verify version
flyway --version
# Flyway Community Edition 10.x.x
Docker
docker run --rm \
-v $(pwd)/sql:/flyway/sql \
-v $(pwd)/conf:/flyway/conf \
flyway/flyway:10-alpine \
migrate
Maven Plugin
<plugin>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-maven-plugin</artifactId>
<version>10.8.1</version>
<configuration>
<url>jdbc:postgresql://localhost:5432/mydb</url>
<user>postgres</user>
<password>secret</password>
<locations>
<location>filesystem:src/main/resources/db/migration</location>
</locations>
</configuration>
</plugin>
Gradle Plugin
plugins {
id 'org.flywaydb.flyway' version '10.8.1'
}
flyway {
url = 'jdbc:postgresql://localhost:5432/mydb'
user = 'postgres'
password = 'secret'
locations = ['filesystem:src/main/resources/db/migration']
}
3.3 Naming Convention
Flyway migration file naming rules:
V{version}__{description}.sql # Versioned Migration
R__{description}.sql # Repeatable Migration
U{version}__{description}.sql # Undo Migration (Teams)
Key points:
- Prefix: V (Versioned), R (Repeatable), U (Undo)
- Version: numeric (1, 2, 3) or date-based (20250325) or timestamp
- Separator: double underscore (
__) - Description: English words separated by underscores
-- V1__Create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
-- V2__Create_orders_table.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
-- V3__Add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN profile_image_url TEXT;
-- R__Create_reporting_views.sql (re-executed every time the file changes)
CREATE OR REPLACE VIEW v_user_order_summary AS
SELECT
u.id AS user_id,
u.username,
u.email,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username, u.email;
3.4 Core Commands
# Execute migrations
flyway migrate
# Check current status
flyway info
# Validate migration integrity
flyway validate
# Drop all objects (CAUTION: destroys everything)
flyway clean
# Apply Flyway to existing database (baseline)
flyway baseline
# Repair failed migrations
flyway repair
Example info output
+-----------+---------+---------------------+------+---------------------+---------+----------+
| Category | Version | Description | Type | Installed On | State | Undoable |
+-----------+---------+---------------------+------+---------------------+---------+----------+
| Versioned | 1 | Create users table | SQL | 2025-03-20 10:15:30 | Success | No |
| Versioned | 2 | Create orders table | SQL | 2025-03-20 10:15:31 | Success | No |
| Versioned | 3 | Add phone to users | SQL | | Pending | No |
+-----------+---------+---------------------+------+---------------------+---------+----------+
3.5 Configuration
flyway.conf (traditional format)
# Database connection
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=postgres
flyway.password=secret
# Migration locations
flyway.locations=filesystem:./sql,classpath:db/migration
# Schemas
flyway.schemas=public,app
flyway.defaultSchema=app
# History table
flyway.table=flyway_schema_history
# Encoding
flyway.encoding=UTF-8
# Placeholders
flyway.placeholders.env=production
flyway.placeholders.schema_name=app
# Validation
flyway.validateOnMigrate=true
flyway.validateMigrationNaming=true
# Miscellaneous
flyway.outOfOrder=false
flyway.baselineOnMigrate=false
flyway.cleanDisabled=true
flyway.toml (Flyway v10 new format)
[environments.default]
url = "jdbc:postgresql://localhost:5432/mydb"
user = "postgres"
password = "secret"
[flyway]
locations = ["filesystem:./sql"]
defaultSchema = "app"
table = "flyway_schema_history"
validateOnMigrate = true
cleanDisabled = true
[flyway.placeholders]
env = "production"
schema_name = "app"
3.6 Placeholders and Environment-Specific Configuration
-- V5__Create_audit_table.sql
CREATE TABLE ${schema_name}.audit_log (
id BIGSERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
operation VARCHAR(10) NOT NULL,
old_data JSONB,
new_data JSONB,
changed_by VARCHAR(100),
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE ${schema_name}.audit_log IS 'Audit log for ${env} environment';
Environment-specific config files:
# flyway-dev.conf
flyway.url=jdbc:postgresql://localhost:5432/mydb_dev
flyway.placeholders.env=development
# flyway-staging.conf
flyway.url=jdbc:postgresql://staging-db:5432/mydb
flyway.placeholders.env=staging
# flyway-prod.conf
flyway.url=jdbc:postgresql://prod-db:5432/mydb
flyway.placeholders.env=production
flyway.cleanDisabled=true
# Run per environment
flyway -configFiles=flyway-dev.conf migrate
flyway -configFiles=flyway-staging.conf migrate
flyway -configFiles=flyway-prod.conf migrate
3.7 Callbacks
Flyway supports callbacks that run before and after migration operations.
Callback file names:
beforeMigrate.sql - Before migrate
beforeEachMigrate.sql - Before each migration
afterEachMigrate.sql - After each migration
afterMigrate.sql - After migrate completes
beforeClean.sql - Before clean
afterClean.sql - After clean completes
beforeValidate.sql - Before validate
afterValidate.sql - After validate completes
-- afterMigrate.sql
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_stats;
ANALYZE users;
ANALYZE orders;
INSERT INTO migration_audit_log (action, executed_at)
VALUES ('migration_completed', NOW());
3.8 Java-Based Migrations
For complex logic, you can write migrations in Java.
// V6__Encrypt_user_emails.java
package db.migration;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.*;
public class V6__Encrypt_user_emails extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
Connection conn = context.getConnection();
String selectSql = "SELECT id, email FROM users WHERE encrypted_email IS NULL";
String updateSql = "UPDATE users SET encrypted_email = ? WHERE id = ?";
try (Statement select = conn.createStatement();
PreparedStatement update = conn.prepareStatement(updateSql)) {
ResultSet rs = select.executeQuery(selectSql);
int batchSize = 0;
while (rs.next()) {
long id = rs.getLong("id");
String email = rs.getString("email");
String encrypted = encrypt(email);
update.setString(1, encrypted);
update.setLong(2, id);
update.addBatch();
if (++batchSize % 1000 == 0) {
update.executeBatch();
}
}
if (batchSize % 1000 != 0) {
update.executeBatch();
}
}
}
private String encrypt(String value) {
// Actual encryption logic here
return "ENC:" + value; // Example placeholder
}
}
3.9 Spring Boot Integration
# application.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: '0'
validate-on-migrate: true
out-of-order: false
table: flyway_schema_history
schemas:
- public
placeholders:
env: ${SPRING_PROFILES_ACTIVE:dev}
clean-disabled: true
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: postgres
password: secret
// FlywayConfig.java
@Configuration
public class FlywayConfig {
@Bean
public FlywayMigrationStrategy flywayMigrationStrategy() {
return flyway -> {
MigrationInfo[] pending = flyway.info().pending();
if (pending.length > 0) {
System.out.println("Pending migrations: " + pending.length);
for (MigrationInfo info : pending) {
System.out.println(" - " + info.getVersion()
+ ": " + info.getDescription());
}
}
flyway.migrate();
};
}
}
3.10 Full Working Example with PostgreSQL
my-app/
├── src/main/resources/
│ └── db/
│ └── migration/
│ ├── V1__Create_schema.sql
│ ├── V2__Create_users.sql
│ ├── V3__Create_products.sql
│ ├── V4__Create_orders.sql
│ ├── V5__Add_indexes.sql
│ ├── V6__Create_audit_triggers.sql
│ └── R__Update_views.sql
├── flyway.toml
├── pom.xml
└── docker-compose.yml
# docker-compose.yml
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
flyway:
image: flyway/flyway:10-alpine
depends_on:
- db
volumes:
- ./src/main/resources/db/migration:/flyway/sql
- ./flyway.toml:/flyway/flyway.toml
command: migrate
environment:
FLYWAY_URL: jdbc:postgresql://db:5432/myapp
FLYWAY_USER: postgres
FLYWAY_PASSWORD: secret
volumes:
pgdata:
4. Liquibase Comparison
4.1 Changelog Formats
Liquibase supports four formats: XML, YAML, SQL, and JSON.
YAML Format (most readable)
# db/changelog/db.changelog-master.yaml
databaseChangeLog:
- changeSet:
id: 1
author: dev-team
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: username
type: varchar(50)
constraints:
nullable: false
unique: true
- column:
name: email
type: varchar(255)
constraints:
nullable: false
unique: true
- column:
name: created_at
type: timestamp with time zone
defaultValueComputed: NOW()
rollback:
- dropTable:
tableName: users
- changeSet:
id: 2
author: dev-team
changes:
- addColumn:
tableName: users
columns:
- column:
name: phone
type: varchar(20)
rollback:
- dropColumn:
tableName: users
columnName: phone
SQL Format
-- changeset dev-team:1
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- rollback DROP TABLE users;
-- changeset dev-team:2
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- rollback ALTER TABLE users DROP COLUMN phone;
4.2 Key Liquibase Features
# Run migrations
liquibase update
# Rollback last N changesets
liquibase rollbackCount 1
# Rollback to specific tag
liquibase rollback v1.0
# Diff current DB against changelog
liquibase diff
# Generate changelog from existing DB
liquibase generateChangeLog
# Check status
liquibase status
4.3 Flyway vs Liquibase Selection Guide
Choose Flyway when:
- You want to write raw SQL directly
- You prefer a simple workflow
- You are in the Spring Boot ecosystem
- Your team is comfortable with SQL
Choose Liquibase when:
- Rollback support is mandatory
- Multi-database vendor support is needed
- You need to reverse-engineer existing DB into a changelog
- You prefer XML/YAML declarative management
- You need fine-grained context/conditional execution
5. ORM Migration Systems
5.1 Prisma Migrate (TypeScript/Node.js)
Schema Definition
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
phone String?
posts Post[]
orders Order[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
@@map("posts")
}
model Order {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int @map("user_id")
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status OrderStatus @default(PENDING)
items OrderItem[]
createdAt DateTime @default(now()) @map("created_at")
@@map("orders")
}
model OrderItem {
id Int @id @default(autoincrement())
order Order @relation(fields: [orderId], references: [id])
orderId Int @map("order_id")
product String
quantity Int
price Decimal @db.Decimal(10, 2)
@@map("order_items")
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
Migration Commands
# Development migration (create + apply)
npx prisma migrate dev --name create_initial_schema
# Production migration (apply only)
npx prisma migrate deploy
# Reset database
npx prisma migrate reset
# Check migration status
npx prisma migrate status
# Diff schema vs database
npx prisma migrate diff \
--from-schema-datamodel prisma/schema.prisma \
--to-schema-datasource prisma/schema.prisma
Generated Migration File
-- prisma/migrations/20250325_create_initial_schema/migration.sql
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED');
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"phone" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "posts" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"author_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "posts_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
ALTER TABLE "posts"
ADD CONSTRAINT "posts_author_id_fkey"
FOREIGN KEY ("author_id") REFERENCES "users"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
Shadow Database Concept
Prisma Migrate uses a Shadow Database to verify migration correctness:
1. Create temporary Shadow DB
2. Apply all existing migrations to Shadow DB
3. Generate new migration SQL
4. Verify new migration on Shadow DB
5. Delete Shadow DB
5.2 TypeORM Migrations
Entity Definition
// src/entities/User.ts
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn, OneToMany
} from 'typeorm';
import { Order } from './Order';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 50, unique: true })
username: string;
@Column({ type: 'varchar', length: 255, unique: true })
email: string;
@Column({ type: 'varchar', length: 20, nullable: true })
phone: string;
@OneToMany(() => Order, order => order.user)
orders: Order[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
Migration Generation and Execution
# Auto-generate migration from entity changes
npx typeorm migration:generate -d src/data-source.ts src/migrations/AddPhoneToUser
# Create empty migration
npx typeorm migration:create src/migrations/SeedAdminUser
# Run migrations
npx typeorm migration:run -d src/data-source.ts
# Revert last migration
npx typeorm migration:revert -d src/data-source.ts
Auto-Generated Migration Example
// src/migrations/1711234567890-AddPhoneToUser.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPhoneToUser1711234567890 implements MigrationInterface {
name = 'AddPhoneToUser1711234567890';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "users" ADD "phone" varchar(20)`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "users" DROP COLUMN "phone"`
);
}
}
5.3 Alembic (Python/SQLAlchemy)
Initial Setup
# Install Alembic
pip install alembic sqlalchemy psycopg2-binary
# Initialize project
alembic init alembic
# alembic/env.py (key section)
from myapp.models import Base
target_metadata = Base.metadata
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
Migration Generation and Execution
# Auto-generate (detect model changes)
alembic revision --autogenerate -m "add phone to users"
# Manual creation
alembic revision -m "seed initial data"
# Upgrade to latest
alembic upgrade head
# Upgrade to specific version
alembic upgrade abc123
# Rollback one step
alembic downgrade -1
# Check current state
alembic current
# View migration history
alembic history
Auto-Generated Migration
# alembic/versions/abc123_add_phone_to_users.py
"""add phone to users"""
from alembic import op
import sqlalchemy as sa
revision = 'abc123'
down_revision = 'prev456'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('users',
sa.Column('phone', sa.String(20), nullable=True)
)
op.create_index('ix_users_phone', 'users', ['phone'])
def downgrade():
op.drop_index('ix_users_phone', 'users')
op.drop_column('users', 'phone')
5.4 Django Migrations
# models.py
from django.db import models
class User(models.Model):
username = models.CharField(max_length=50, unique=True)
email = models.EmailField(unique=True)
phone = models.CharField(max_length=20, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'users'
# Create migration
python manage.py makemigrations
# Apply migrations
python manage.py migrate
# Check migration status
python manage.py showmigrations
# Preview SQL
python manage.py sqlmigrate myapp 0002
# Squash migrations
python manage.py squashmigrations myapp 0001 0005
Data Migration
# migrations/0003_populate_default_roles.py
from django.db import migrations
def create_roles(apps, schema_editor):
Role = apps.get_model('myapp', 'Role')
roles = ['admin', 'editor', 'viewer']
for role_name in roles:
Role.objects.get_or_create(name=role_name)
def remove_roles(apps, schema_editor):
Role = apps.get_model('myapp', 'Role')
Role.objects.filter(name__in=['admin', 'editor', 'viewer']).delete()
class Migration(migrations.Migration):
dependencies = [
('myapp', '0002_create_role_table'),
]
operations = [
migrations.RunPython(create_roles, remove_roles),
]
6. Zero-Downtime Migration Strategies
6.1 Expand-Contract Pattern (Most Important)
This is the essential pattern for zero-downtime schema changes. It splits the change into three safe phases.
Example: Splitting username into first_name + last_name
Phase 1 (Expand): Add new columns, write to both
Phase 2 (Migrate): Backfill data, switch reads
Phase 3 (Contract): Remove old column
Phase 1: Expand
-- Migration V10: Add new columns
ALTER TABLE users ADD COLUMN first_name VARCHAR(50);
ALTER TABLE users ADD COLUMN last_name VARCHAR(50);
-- Trigger for bidirectional sync
CREATE OR REPLACE FUNCTION sync_user_names()
RETURNS TRIGGER AS $func$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
IF NEW.first_name IS NULL AND NEW.username IS NOT NULL THEN
NEW.first_name := split_part(NEW.username, ' ', 1);
NEW.last_name := split_part(NEW.username, ' ', 2);
ELSIF NEW.username IS NULL AND NEW.first_name IS NOT NULL THEN
NEW.username := NEW.first_name || ' ' || COALESCE(NEW.last_name, '');
END IF;
END IF;
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_user_names
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_names();
Deploy at this point: new code writes to first_name/last_name
Old code writes to username (trigger syncs)
Phase 2: Migrate
-- Migration V11: Backfill existing data
UPDATE users
SET first_name = split_part(username, ' ', 1),
last_name = split_part(username, ' ', 2)
WHERE first_name IS NULL;
-- Add NOT NULL constraint
ALTER TABLE users ALTER COLUMN first_name SET NOT NULL;
Phase 3: Contract
-- Migration V12: Remove old column (after all code has switched)
DROP TRIGGER IF EXISTS trg_sync_user_names ON users;
DROP FUNCTION IF EXISTS sync_user_names();
ALTER TABLE users DROP COLUMN username;
6.2 Column Rename Without Downtime
Directly renaming a column causes service interruption. The safe approach:
Step 1: Add new column + trigger
Step 2: Copy data
Step 3: Switch code to new column
Step 4: Remove old column
6.3 Safely Adding NOT NULL Constraints
-- Dangerous: full table lock
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- Safe: PostgreSQL 12+ CHECK constraint
ALTER TABLE users ADD CONSTRAINT users_email_not_null
CHECK (email IS NOT NULL) NOT VALID;
-- Validate in separate transaction (no AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;
-- Then convert to NOT NULL constraint
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_email_not_null;
6.4 Large Table ALTER TABLE Strategies
MySQL: gh-ost (GitHub Online Schema Change)
gh-ost \
--host=db-primary \
--database=myapp \
--table=users \
--alter="ADD COLUMN phone VARCHAR(20)" \
--execute \
--allow-on-master \
--chunk-size=1000 \
--max-load=Threads_running=25 \
--critical-load=Threads_running=100
MySQL: pt-online-schema-change (Percona Toolkit)
pt-online-schema-change \
--alter "ADD COLUMN phone VARCHAR(20)" \
--host=db-primary \
--user=admin \
--ask-pass \
--chunk-size=500 \
--max-lag=1s \
--execute \
D=myapp,t=users
PostgreSQL: CREATE INDEX CONCURRENTLY
-- Dangerous: table lock
CREATE INDEX idx_users_email ON users(email);
-- Safe: concurrent index creation (no lock)
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
-- Note: cannot be used inside a transaction
-- In Flyway, disable transaction for this migration
-- Using CONCURRENTLY in Flyway
-- V15__Add_index_concurrently.sql
-- Disable Flyway transaction (comment at top of file)
-- flyway:executeInTransaction=false
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email
ON users(email);
6.5 Safely Adding Foreign Keys
-- Dangerous: full table scan + lock
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id);
-- Safe: add NOT VALID then validate separately
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;
7. Rollback Strategies
7.1 Forward-Only vs Rollback Support
Forward-Only (Flyway default):
V1 → V2 → V3 → V4
If V4 has issues → create V5 to fix
Rollback Support (Liquibase, Flyway Teams):
V1 → V2 → V3 → V4
If V4 has issues → rollback to V3
7.2 Flyway Undo Migrations (Teams Edition)
-- U4__Remove_status_column.sql (Undo for V4)
ALTER TABLE orders DROP COLUMN IF EXISTS status;
# Execute undo
flyway undo
# Undo to specific version
flyway undo -target=2
7.3 Compensating Migrations
For free-tier users who need rollback, write compensating migrations:
-- V4__Add_status_to_orders.sql (original migration)
ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending';
-- V5__Revert_status_from_orders.sql (compensating migration)
ALTER TABLE orders DROP COLUMN IF EXISTS status;
7.4 When NOT to Rollback
Do NOT rollback when:
1. Column deletion caused data loss
- DROP COLUMN cannot be recovered
- Always verify backups before deletion
2. Data transformation destroyed originals
- UPDATE changed data format
- Original data was not preserved
3. Large-scale data migration
- Millions of rows moved
- Rollback time exceeds acceptable limits
4. External system integration
- API schema changes adopted by external consumers
- Cannot rollback external systems
8. CI/CD Integration
8.1 GitHub Actions Pipeline
# .github/workflows/db-migration.yml
name: Database Migration
on:
push:
branches: [main]
paths:
- 'db/migration/**'
pull_request:
paths:
- 'db/migration/**'
jobs:
validate:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Run Flyway Validate
run: |
docker run --rm --network host \
-v $(pwd)/db/migration:/flyway/sql \
flyway/flyway:10-alpine \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=postgres \
-password=testpass \
validate
- name: Run Flyway Info
run: |
docker run --rm --network host \
-v $(pwd)/db/migration:/flyway/sql \
flyway/flyway:10-alpine \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=postgres \
-password=testpass \
info
- name: Test Migration
run: |
docker run --rm --network host \
-v $(pwd)/db/migration:/flyway/sql \
flyway/flyway:10-alpine \
-url=jdbc:postgresql://localhost:5432/testdb \
-user=postgres \
-password=testpass \
migrate
- name: Verify Schema
run: |
PGPASSWORD=testpass psql -h localhost -U postgres -d testdb -c "
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
"
deploy-staging:
needs: validate
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to Staging
run: |
docker run --rm \
-v $(pwd)/db/migration:/flyway/sql \
flyway/flyway:10-alpine \
-url=$STAGING_DB_URL \
-user=$STAGING_DB_USER \
-password=$STAGING_DB_PASSWORD \
migrate
env:
STAGING_DB_URL: ${{ secrets.STAGING_DB_URL }}
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
deploy-production:
needs: deploy-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to Production
run: |
docker run --rm \
-v $(pwd)/db/migration:/flyway/sql \
flyway/flyway:10-alpine \
-url=$PROD_DB_URL \
-user=$PROD_DB_USER \
-password=$PROD_DB_PASSWORD \
-validateOnMigrate=true \
migrate
env:
PROD_DB_URL: ${{ secrets.PROD_DB_URL }}
PROD_DB_USER: ${{ secrets.PROD_DB_USER }}
PROD_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
8.2 Blue-Green Deployment with DB Migration
Timeline →
1. [Blue active] DB v1, App v1
2. [Blue active] DB v1 → v2 migration (backward compatible)
3. [Blue active] Deploy App v2 to Green
4. [Blue → Green switch] Traffic cutover
5. [Green active] Decommission Blue
Key: DB migration MUST be backward compatible
Old version code must work with new schema
9. Kubernetes Environment
9.1 Init Container Approach
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
initContainers:
- name: flyway-migrate
image: flyway/flyway:10-alpine
args: ["migrate"]
env:
- name: FLYWAY_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
- name: FLYWAY_USER
valueFrom:
secretKeyRef:
name: db-secret
key: username
- name: FLYWAY_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
volumeMounts:
- name: migrations
mountPath: /flyway/sql
containers:
- name: myapp
image: myapp:latest
ports:
- containerPort: 8080
volumes:
- name: migrations
configMap:
name: flyway-migrations
9.2 Kubernetes Job Approach
# k8s/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: flyway-migrate-v10
labels:
app: flyway-migration
spec:
backoffLimit: 3
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: Never
containers:
- name: flyway
image: flyway/flyway:10-alpine
args: ["migrate"]
env:
- name: FLYWAY_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
- name: FLYWAY_USER
valueFrom:
secretKeyRef:
name: db-secret
key: username
- name: FLYWAY_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
- name: FLYWAY_VALIDATE_ON_MIGRATE
value: "true"
- name: FLYWAY_CONNECT_RETRIES
value: "5"
volumeMounts:
- name: migrations
mountPath: /flyway/sql
volumes:
- name: migrations
configMap:
name: flyway-migrations
9.3 Helm Hooks
# helm/templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ .Release.Name }}-flyway-migrate"
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: flyway
image: "flyway/flyway:{{ .Values.flyway.version }}"
args: ["migrate"]
env:
- name: FLYWAY_URL
value: "jdbc:postgresql://{{ .Values.db.host }}:{{ .Values.db.port }}/{{ .Values.db.name }}"
- name: FLYWAY_USER
valueFrom:
secretKeyRef:
name: "{{ .Values.db.secretName }}"
key: username
- name: FLYWAY_PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Values.db.secretName }}"
key: password
volumeMounts:
- name: migrations
mountPath: /flyway/sql
volumes:
- name: migrations
configMap:
name: "{{ .Release.Name }}-migrations"
9.4 Handling Concurrent Migrations (Locking)
You must prevent multiple Pods from running migrations simultaneously.
Flyway built-in locking:
- flyway_schema_history table has built-in lock mechanism
- First instance runs the migration
- Others wait and then skip
Additional safeguards:
- K8s Job (single Pod) recommended
- Init Container approach: each Pod attempts (Flyway handles locking)
- DB Advisory Lock for extra safety
-- Custom locking mechanism (Advisory Lock)
-- beforeMigrate.sql
SELECT pg_advisory_lock(12345);
-- afterMigrate.sql
SELECT pg_advisory_unlock(12345);
9.5 ArgoCD + Flyway Integration
# argocd/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myapp
spec:
project: default
source:
repoURL: https://github.com/myorg/myapp.git
path: helm
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
retry:
limit: 3
backoff:
duration: 5s
factor: 2
maxDuration: 3m
ArgoCD Sync Flow:
1. Detect Git changes
2. Helm hook (pre-upgrade) → Execute Flyway Job
3. Flyway migration succeeds
4. Application Deployment updates
5. ArgoCD sync complete
10. Best Practices and Anti-Patterns
10.1 Best Practices
1. One migration = one change
Good:
V1__Create_users_table.sql
V2__Add_email_index.sql
V3__Create_orders_table.sql
Bad:
V1__Create_all_tables_and_indexes_and_data.sql
2. Never modify applied migrations
Never do this:
- Edit an already-executed V1__Create_users.sql
- Flyway detects changes via checksum → error
Correct approach:
- Create a new migration file (V5__Fix_users_table.sql)
3. Test migrations on production-like data
# Test with production-scale data
# Testing ALTER TABLE on a 10 million row table
# Generate test data
INSERT INTO users_test (username, email)
SELECT
'user_' || generate_series,
'user_' || generate_series || '@example.com'
FROM generate_series(1, 10000000);
# Measure migration execution time
\timing on
ALTER TABLE users_test ADD COLUMN phone VARCHAR(20);
# Time: 45123.456 ms (~45 seconds) - 45 seconds of locking in production!
4. Separate schema and data migrations
V10__Add_status_column.sql # Schema change
V11__Backfill_status_data.sql # Data change
V12__Add_status_not_null.sql # Add constraint
5. Batch process large data migrations
-- Bad: update everything at once
UPDATE users SET normalized_email = LOWER(email);
-- Good: batch processing
DO $block$
DECLARE
batch_size INT := 10000;
total_updated INT := 0;
rows_affected INT;
BEGIN
LOOP
UPDATE users
SET normalized_email = LOWER(email)
WHERE id IN (
SELECT id FROM users
WHERE normalized_email IS NULL
LIMIT batch_size
);
GET DIAGNOSTICS rows_affected = ROW_COUNT;
total_updated := total_updated + rows_affected;
RAISE NOTICE 'Updated % rows (total: %)', rows_affected, total_updated;
EXIT WHEN rows_affected = 0;
PERFORM pg_sleep(0.1);
END LOOP;
END;
$block$;
10.2 Anti-Patterns
Anti-pattern 1: Running DDL directly on production
→ Always go through migration files
Anti-pattern 2: Different migration files per environment
→ Same migrations in all environments (use placeholders)
Anti-pattern 3: Business logic in migrations
→ Migrations for schema/data changes only. Logic belongs in application
Anti-pattern 4: Ignoring backward compatibility
→ For zero-downtime deployment, old code must work with new schema
Anti-pattern 5: Deploying to production without testing
→ Always validate in CI with test database
Anti-pattern 6: Deploying without a rollback plan
→ Prepare rollback/compensating strategy for every migration
Anti-pattern 7: Not version-locking migration tools
→ Use lock files or explicit version pinning
11. Production Deployment Checklist
Items to verify before every deployment:
Pre-Deployment Review:
[ ] Migration SQL code reviewed
[ ] Backward compatibility verified (works with old code)
[ ] Tested on production-like data
[ ] Execution time measured (watch out for large tables)
[ ] Lock impact analyzed
[ ] Rollback/compensating migration prepared
During Deployment:
[ ] Database backup confirmed
[ ] Monitoring dashboard checked (CPU, connections, slow queries)
[ ] Execute within maintenance window (if needed)
[ ] Run flyway validate first
[ ] Verify flyway info after migration
[ ] Application health check confirmed
Post-Deployment:
[ ] Schema changes verified (tables, indexes, constraints)
[ ] Application functionality confirmed
[ ] Performance monitoring (query performance changes)
[ ] Error logs reviewed
[ ] Team notified (changes, impact)
12. Quiz
Q1: Flyway Naming Convention
What is the prefix for Repeatable migrations in Flyway?
Answer: R__
Flyway migration file prefixes:
V- Versioned Migration (executed once)R- Repeatable Migration (re-executed whenever the file changes)U- Undo Migration (Teams edition, for rollback)
Repeatable migrations are used for objects that need to be recreated every time, such as views, stored procedures, and functions.
Q2: Expand-Contract Pattern
Describe the three phases of the Expand-Contract pattern for zero-downtime schema changes.
Answer:
-
Expand: Add new columns or tables. Ensure both old and new code can operate. New columns should be nullable.
-
Migrate: Copy or transform existing data to the new structure. At this stage, new code switches to using the new columns.
-
Contract: After confirming all code uses the new structure, remove the old columns or tables.
The key insight is that each phase is a separate deployment. Never combine all phases into a single deployment.
Q3: Prisma Shadow Database
What is the purpose of the Shadow Database in Prisma Migrate?
Answer:
The Shadow Database is a temporary database that Prisma Migrate uses to verify migration correctness.
How it works:
- Creates a temporary database
- Applies all existing migration files in order
- Compares against the current Prisma schema to detect differences
- Generates new migration SQL
- Applies generated SQL to Shadow DB for verification
- Deletes the temporary database
This detects migration drift and prevents incorrect migration files.
Q4: CI/CD Migration Testing
How do you safely test DB migrations in a CI/CD pipeline?
Answer:
-
Use a test database: Spin up a temporary DB in CI using Docker.
-
Run Flyway validate: Verify migration file integrity first.
-
Apply all migrations: Execute all migrations in order on an empty DB.
-
Verify schema: Confirm expected tables, columns, and indexes exist.
-
Performance test with production-like data: Measure migration execution time and lock impact on large datasets.
-
Validate in staging: Apply to staging environment before production.
-
Approval gates: Add manual approval step before production deployment.
Q5: When NOT to Rollback
Name two scenarios where you should NOT rollback a database migration.
Answer:
-
Column deletion caused data loss: A DROP COLUMN migration cannot be rolled back because the data is already gone. Always verify backups before deletion.
-
Irreversible data transformation: If data was transformed (e.g., email hashing) and original data was not preserved, rollback cannot restore the original values.
Additional cases:
- Large-scale data migration where rollback time exceeds service-level tolerance
- External system integration where the external system's state cannot be reverted
In these scenarios, a forward-only approach (compensating migrations) is safer.
13. References
- Flyway Official Documentation
- Flyway GitHub Repository
- Liquibase Official Documentation
- Prisma Migrate Documentation
- TypeORM Migrations Guide
- Alembic Official Documentation
- Django Migrations Official Documentation
- GitHub gh-ost
- Percona pt-online-schema-change
- PostgreSQL ALTER TABLE Best Practices
- Expand-Contract Pattern (Martin Fowler)
- Kubernetes Init Containers
- Flyway Spring Boot Integration