| name | data-migration |
| description | Database migration patterns: zero-downtime schema changes, data backfills, Alembic, Prisma Migrate, Rails migrations |
Data Migration Skill
When to activate
- Adding, removing, or changing columns in a production database
- Renaming tables or columns while keeping the application running
- Backfilling data for a new column or changing data formats
- Writing Alembic, Prisma, Rails, or raw SQL migrations
- Planning a large-scale data transformation safely
When NOT to use
- Small dev-only schema changes where downtime is acceptable
- NoSQL schema migrations (different patterns apply)
- Data warehouse ETL pipelines — use dbt or Spark instead
Instructions
The three rules of safe migrations
1. Migrations must be reversible (or explicitly documented as irreversible)
Every migration should have an up and a down. If a down is impossible (e.g. dropping a column with data), document it explicitly.
2. Code and migration deploy must be decoupled
Deploy the migration BEFORE deploying the code that uses it, or AFTER. Never at the same time. This ensures the database can serve both old and new code during a rolling deploy.
3. Never lock a table in production
Avoid ALTER TABLE with LOCK, large UPDATE statements on full tables, or dropping indexed columns — these lock the table and block all reads/writes.
Zero-downtime migration patterns
Adding a column (safe):
ALTER TABLE users ADD COLUMN phone TEXT;
UPDATE users SET phone = '' WHERE phone IS NULL AND id BETWEEN 1 AND 10000;
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
Renaming a column (expand-contract pattern):
ALTER TABLE users ADD COLUMN full_name TEXT;
UPDATE users SET full_name = name;
ALTER TABLE users DROP COLUMN name;
Removing a column (safe):
ALTER TABLE users DROP COLUMN deprecated_field;
Never: drop a column while code still references it.
Alembic (Python / SQLAlchemy)
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('users', sa.Column('phone', sa.Text(), nullable=True))
def downgrade():
op.drop_column('users', 'phone')
from sqlalchemy import text
BATCH_SIZE = 5000
with engine.connect() as conn:
while True:
result = conn.execute(text("""
UPDATE users SET phone = ''
WHERE phone IS NULL
LIMIT :batch_size
"""), {"batch_size": BATCH_SIZE})
conn.commit()
if result.rowcount < BATCH_SIZE:
break
alembic upgrade head
alembic downgrade -1
alembic current
alembic revision --autogenerate -m "add phone to users"
Prisma Migrate (TypeScript / Node.js)
// schema.prisma — add the new field
model User {
id Int @id @default(autoincrement())
email String @unique
phone String? // nullable first
}
npx prisma migrate dev --name add_phone_to_users
npx prisma migrate deploy
npx prisma migrate status
Prisma data migration (using a custom script):
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const BATCH_SIZE = 1000
async function backfill() {
let cursor: number | undefined
while (true) {
const users = await prisma.user.findMany({
where: { phone: null },
take: BATCH_SIZE,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
orderBy: { id: 'asc' },
})
if (users.length === 0) break
await prisma.user.updateMany({
where: { id: { in: users.map(u => u.id) } },
data: { phone: '' },
})
cursor = users[users. - ].
.()
}
}
().( prisma.$disconnect())
Rails Active Record migrations
class AddPhoneToUsers < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :users, :phone, :string
add_index :users, :phone, algorithm: :concurrently
end
end
rails db:migrate
rails db:rollback
rails db:migrate:status
Large table backfills (production-safe)
Never run UPDATE users SET ... WHERE condition on a large table — it locks the entire table and takes minutes or hours.
PostgreSQL batched backfill:
DO $$
DECLARE
batch_size INT := 5000;
min_id BIGINT;
max_id BIGINT;
current_id BIGINT;
BEGIN
SELECT MIN(id), MAX(id) INTO min_id, max_id FROM users WHERE phone IS NULL;
current_id := min_id;
WHILE current_id <= max_id LOOP
UPDATE users
SET phone = ''
WHERE id BETWEEN current_id AND current_id + batch_size - 1
AND phone IS NULL;
COMMIT;
PERFORM pg_sleep(0.05);
current_id := current_id + batch_size;
END LOOP;
END $$;
Adding indexes safely
CREATE INDEX idx_users_phone ON users (phone);
CREATE INDEX CONCURRENTLY idx_users_phone ON users (phone);
SELECT phase, blocks_done, blocks_total
FROM pg_stat_progress_create_index
WHERE relid = 'users'::regclass;
Migration checklist
Before applying to production:
Example
Task: Rename column name to full_name in the users table with zero downtime.
Migration plan:
ALTER TABLE users ADD COLUMN full_name TEXT — add nullable
UPDATE users SET full_name = name — backfill (in batches)
- Deploy new code that reads
full_name, writes to both name AND full_name
- Verify all reads come from
full_name — monitor logs
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL — enforce NOT NULL
- Deploy code that only writes to
full_name
ALTER TABLE users DROP COLUMN name — remove old column
Total downtime: 0. Total deploys: 2.