Database migration best practices for schema changes, data migrations, rollbacks, and zero-downtime deployments. Use when creating or altering tables, columns, indexes, or planning schema changes.
Safe, reversible database schema changes for production systems.
Before applying any migration:
CREATE INDEX CONCURRENTLY)-- GOOD: Nullable column, no lock
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- GOOD: Column with default (Postgres 11+ is instant)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- BAD: NOT NULL without default on existing table (full rewrite, long lock)
ALTER TABLE users ADD COLUMN role TEXT NOT NULL;
-- BAD: Blocks writes on large tables
CREATE INDEX idx_users_email ON users (email);
-- GOOD: Non-blocking (CONCURRENTLY cannot run inside a transaction in some tools)
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
Do not rename directly in production. Use expand → migrate → contract:
-- Step 1: Add new column (migration 001)
ALTER TABLE users ADD COLUMN display_name TEXT;
-- Step 2: Backfill (migration 002, data only)
UPDATE users SET display_name = username WHERE display_name IS NULL;
-- Step 3: Deploy app that reads/writes both columns, then only new column
-- Step 4: Drop old column (migration 003)
ALTER TABLE users DROP COLUMN username;
-- BAD: Single transaction, locks table
UPDATE users SET normalized_email = LOWER(email);
-- GOOD: Batch with SKIP LOCKED (PostgreSQL)
DO $$
DECLARE batch_size INT := 10000; rows_updated 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 FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
RAISE NOTICE 'Updated % rows', rows_updated;
EXIT WHEN rows_updated = 0;
COMMIT;
END LOOP;
END $$;
npx prisma migrate dev --name add_user_avatar # Create and apply (dev)
npx prisma migrate deploy # Apply pending (prod)
npx prisma migrate reset # Reset (dev only)
npx prisma generate # Regenerate client
For operations Prisma cannot generate (e.g. concurrent index):
npx prisma migrate dev --create-only --name add_email_index
# Edit the generated SQL file to use CREATE INDEX CONCURRENTLY
npx drizzle-kit generate # Generate migration from schema
npx drizzle-kit migrate # Apply migrations
npx drizzle-kit push # Push schema (dev only, no migration file)
python manage.py makemigrations # Generate from models
python manage.py migrate # Apply
python manage.py showmigrations # Status
python manage.py makemigrations --empty app_name -n description # Empty migration
Data migration example:
def backfill_display_names(apps, schema_editor):
User = apps.get_model("accounts", "User")
batch_size = 5000
users = User.objects.filter(display_name="")
while users.exists():
batch = list(users[:batch_size])
for u in batch:
u.display_name = u.username
User.objects.bulk_update(batch, ["display_name"], batch_size=batch_size)
class Migration(migrations.Migration):
dependencies = [("accounts", "0015_add_display_name")]
operations = [migrations.RunPython(backfill_display_names, migrations.RunPython.noop)]
migrate create -ext sql -dir migrations -seq add_user_avatar
migrate -path migrations -database "$DATABASE_URL" up
migrate -path migrations -database "$DATABASE_URL" down 1
Phase 1 — EXPAND: Add new column/table (nullable or with default). Deploy app that writes to BOTH old and new. Backfill data.
Phase 2 — MIGRATE: Deploy app that reads from NEW, still writes to BOTH. Verify.
Phase 3 — CONTRACT: Deploy app that uses only NEW. Then run migration to drop old column/table.
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Manual SQL in production | No audit, unrepeatable | Always use migration files |
| Editing deployed migrations | Drift between environments | Create new migration |
| NOT NULL without default on existing table | Long lock, full rewrite | Add nullable, backfill, then add constraint |
| Inline index on large table | Blocks writes | CREATE INDEX CONCURRENTLY |
| Schema + large data in one migration | Hard rollback, long transaction | Separate schema and data migrations |
| Drop column before removing code | App errors on missing column | Remove code first, drop column next release |