During blue-green deployments, both old and new application versions run simultaneously. This means database migrations must be compatible with both versions of the application code. Unsafe migrations can cause:
- Application crashes in the old version
- Data corruption
- Deployment rollback failures
- Service downtime
This document outlines safe migration patterns enforced by our CI pipeline.
All migrations are automatically checked by scripts/check-migration-safety.sh in CI. The checker enforces these rules:
These patterns will fail CI and block PR merges:
- NOT NULL columns without DEFAULT
- Column renames
- Table drops without deprecation
- Column type changes
These patterns generate warnings but don't block CI:
- Column drops
- Constraints without NOT VALID
- Indexes without CONCURRENTLY
- Foreign keys without NOT VALID
- Enum modifications
Safe: Old app ignores new columns, new app uses them.
-- ✅ SAFE: Nullable column
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS memo TEXT;Safe: Old app ignores column, new app gets default value for existing rows.
-- ✅ SAFE: Column with default
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'pending';Unsafe: Old app tries to insert rows without the new column, violating NOT NULL constraint.
-- ❌ UNSAFE: NOT NULL without default
ALTER TABLE transactions
ADD COLUMN status VARCHAR(20) NOT NULL;Fix: Add a default value:
-- ✅ SAFE: NOT NULL with default
ALTER TABLE transactions
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';Never use RENAME COLUMN - it breaks old app versions immediately.
Instead, use the add+migrate+drop pattern:
-- Add new column with same data type
ALTER TABLE users
ADD COLUMN IF NOT EXISTS username VARCHAR(255);
-- Backfill existing data
UPDATE users SET username = user_name WHERE username IS NULL;Update application code to write to both user_name and username.
Update application code to read from username instead of user_name.
-- Safe to drop after all instances use new column
ALTER TABLE users
DROP COLUMN IF EXISTS user_name;Never drop tables immediately - old app versions may still query them.
Update application code to stop writing to the table.
Ensure all old app instances are replaced.
-- Safe after deprecation period
DROP TABLE IF EXISTS old_table_name;Never use ALTER COLUMN TYPE - it can break old app versions and lock tables.
ALTER TABLE transactions
ADD COLUMN amount_cents BIGINT;
-- Backfill data
UPDATE transactions
SET amount_cents = (amount * 100)::BIGINT
WHERE amount_cents IS NULL;Update application code to use amount_cents.
ALTER TABLE transactions
DROP COLUMN IF EXISTS amount;Use CONCURRENTLY to avoid locking tables:
-- ✅ SAFE: Non-blocking index creation
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transactions_status
ON transactions(status);Without CONCURRENTLY, index creation locks the table and blocks writes.
Use NOT VALID to avoid full table scans and locks:
-- Step 1: Add constraint without validation
ALTER TABLE transactions
ADD CONSTRAINT check_amount_positive
CHECK (amount > 0) NOT VALID;
-- Step 2: Validate in separate transaction (can be done later)
ALTER TABLE transactions
VALIDATE CONSTRAINT check_amount_positive;Similar to constraints, use NOT VALID:
-- Step 1: Add FK without validation
ALTER TABLE transactions
ADD CONSTRAINT fk_settlement
FOREIGN KEY (settlement_id) REFERENCES settlements(id) NOT VALID;
-- Step 2: Validate separately
ALTER TABLE transactions
VALIDATE CONSTRAINT fk_settlement;Adding enum values is generally safe, but old app versions won't recognize them:
-- ✅ SAFE: Adding enum value
ALTER TYPE transaction_status ADD VALUE IF NOT EXISTS 'disputed';Important: Ensure old app code handles unknown enum values gracefully (e.g., treats them as a default state).
Before submitting a PR with migrations:
- Run
./scripts/check-migration-safety.shlocally - All blocking errors are resolved
- Warnings are reviewed and acceptable
- Multi-step migrations are documented in PR description
- Deployment order is clear (if multiple migrations)
- Rollback plan is documented
# Check migration safety
./scripts/check-migration-safety.sh
# Test migration forward
sqlx migrate run
# Test migration backward (if .down.sql exists)
sqlx migrate revertThe CI pipeline automatically:
- Runs migration safety checks
- Applies migrations to test database
- Runs application tests against migrated schema
Wrong approach:
ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL;Right approach:
-- Migration 1: Add nullable column
ALTER TABLE users ADD COLUMN email VARCHAR(255);
-- Migration 2 (after app deployment): Make it required
ALTER TABLE users ALTER COLUMN email SET NOT NULL;Example: Split full_name into first_name and last_name
-- Migration 1: Add new columns
ALTER TABLE users
ADD COLUMN first_name VARCHAR(255),
ADD COLUMN last_name VARCHAR(255);
-- Backfill data
UPDATE users
SET
first_name = split_part(full_name, ' ', 1),
last_name = split_part(full_name, ' ', 2)
WHERE first_name IS NULL;
-- Deploy app that uses new columns
-- Migration 2: Drop old column
ALTER TABLE users DROP COLUMN full_name;-- Migration 1: Add column as nullable with default
ALTER TABLE transactions
ADD COLUMN status VARCHAR(20) DEFAULT 'pending';
-- Backfill existing rows
UPDATE transactions SET status = 'pending' WHERE status IS NULL;
-- Migration 2 (after backfill): Make it NOT NULL
ALTER TABLE transactions
ALTER COLUMN status SET NOT NULL;Always provide .down.sql migrations for rollback:
-- 20260429000000_add_user_email.sql
ALTER TABLE users ADD COLUMN email VARCHAR(255);
-- 20260429000000_add_user_email.down.sql
ALTER TABLE users DROP COLUMN IF EXISTS email;Important: Down migrations should also follow safety rules. Dropping columns immediately may break the app version being rolled back to.
- PostgreSQL ALTER TABLE Documentation
- Zero-Downtime Migrations
- Strong Migrations (Ruby, but principles apply)
If you're unsure whether a migration is safe:
- Run
./scripts/check-migration-safety.sh - Review this document
- Ask in #engineering-database channel
- Consider breaking the migration into multiple steps
Remember: It's better to deploy in multiple steps than to cause downtime.