Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Database migration best practices for schema changes, data migrations, rollbacks, and zero-downtime deployments across PostgreSQL, MySQL, and common ORMs (Prisma, Drizzle, Django, TypeORM, golang-migrate). Use when planning or implementing database schema changes.
.claude/skills/affaan-m-database-migrations/SKILL.md| Model | Eval pass | Runs |
|---|---|---|
| gemini-3.6-flash | 80% | 10 |
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-15 | ✗→✓ | ▲ Improved | 113% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 108% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 72% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 136% | 0% |
| case-21 | ✓→✓ | = Same ✓ | 104% | 0% |
为生产系统提供安全、可逆的数据库模式变更。
应用任何迁移之前:
sql-- GOOD: Nullable column, no lock ALTER TABLE users ADD COLUMN avatar_url TEXT; -- GOOD: Column with default (Postgres 11+ is instant, no rewrite) ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true; -- BAD: NOT NULL without default on existing table (requires full rewrite) ALTER TABLE users ADD COLUMN role TEXT NOT NULL; -- This locks the table and rewrites every row
sql-- BAD: Blocks writes on large tables CREATE INDEX idx_users_email ON users (email); -- GOOD: Non-blocking, allows concurrent writes CREATE INDEX CONCURRENTLY idx_users_email ON users (email); -- Note: CONCURRENTLY cannot run inside a transaction block -- Most migration tools need special handling for this
切勿在生产中直接重命名。使用扩展-收缩模式:
sql-- Step 1: Add new column (migration 001) ALTER TABLE users ADD COLUMN display_name TEXT; -- Step 2: Backfill data (migration 002, data migration) UPDATE users SET display_name = username WHERE display_name IS NULL; -- Step 3: Update application code to read/write both columns -- Deploy application changes -- Step 4: Stop writing to old column, drop it (migration 003) ALTER TABLE users DROP COLUMN username;
sql-- Step 1: Remove all application references to the column -- Step 2: Deploy application without the column reference -- Step 3: Drop column in next migration ALTER TABLE orders DROP COLUMN legacy_status; -- For Django: use SeparateDatabaseAndState to remove from model -- without generating DROP COLUMN (then drop in next migration)
sql-- BAD: Updates all rows in one transaction (locks table) UPDATE users SET normalized_email = LOWER(email); -- GOOD: Batch update with progress 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 $$;
bash# Create migration from schema changes npx prisma migrate dev --name add_user_avatar # Apply pending migrations in production npx prisma migrate deploy # Reset database (dev only) npx prisma migrate reset # Generate client after schema changes npx prisma generate
prismamodel User { id String @id @default(cuid()) email String @unique name String? avatarUrl String? @map("avatar_url") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") orders Order[] @@map("users") @@index([email]) }
对于 Prisma 无法表达的操作(并发索引、数据回填):
bash# Create empty migration, then edit the SQL manually npx prisma migrate dev --create-only --name add_email_index
sql-- migrations/20240115_add_email_index/migration.sql -- Prisma cannot generate CONCURRENTLY, so we write it manually CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
bash# Generate migration from schema changes npx drizzle-kit generate # Apply migrations npx drizzle-kit migrate # Push schema directly (dev only, no migration file) npx drizzle-kit push
typescriptimport { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core"; export const users = pgTable("users", { id: uuid("id").primaryKey().defaultRandom(), email: text("email").notNull().unique(), name: text("name"), isActive: boolean("is_active").notNull().default(true), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), });
bash# Generate migration from model changes python manage.py makemigrations # Apply migrations python manage.py migrate # Show migration status python manage.py showmigrations # Generate empty migration for custom SQL python manage.py makemigrations --empty app_name -n description
pythonfrom django.db import migrations 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 user in batch: user.display_name = user.username User.objects.bulk_update(batch, ["display_name"], batch_size=batch_size) def reverse_backfill(apps, schema_editor): pass # Data migration, no reverse needed class Migration(migrations.Migration): dependencies = [("accounts", "0015_add_display_name")] operations = [ migrations.RunPython(backfill_display_names, reverse_backfill), ]
从 Django 模型中删除列,而不立即从数据库中删除:
pythonclass Migration(migrations.Migration): operations = [ migrations.SeparateDatabaseAndState( state_operations=[ migrations.RemoveField(model_name="user", name="legacy_field"), ], database_operations=[], # Don't touch the DB yet ), ]
bash# Create migration pair migrate create -ext sql -dir migrations -seq add_user_avatar # Apply all pending migrations migrate -path migrations -database "$DATABASE_URL" up # Rollback last migration migrate -path migrations -database "$DATABASE_URL" down 1 # Force version (fix dirty state) migrate -path migrations -database "$DATABASE_URL" force VERSION
sql-- migrations/000003_add_user_avatar.up.sql ALTER TABLE users ADD COLUMN avatar_url TEXT; CREATE INDEX CONCURRENTLY idx_users_avatar ON users (avatar_url) WHERE avatar_url IS NOT NULL; -- migrations/000003_add_user_avatar.down.sql DROP INDEX IF EXISTS idx_users_avatar; ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
对于关键的生产变更,遵循扩展-收缩模式:
Phase 1: EXPAND
- 添加新列/表(可为空或带有默认值)
- 部署:应用同时写入旧数据和新数据
- 回填现有数据
Phase 2: MIGRATE
- 部署:应用读取新数据,同时写入新旧数据
- 验证数据一致性
Phase 3: CONTRACT
- 部署:应用仅使用新数据
- 在单独迁移中删除旧列/表Day 1:迁移添加新的 `new_status` 列(可空)
Day 1:部署应用 v2 —— 同时写入 `status` 和 `new_status`
Day 2:运行针对现有行的回填迁移
Day 3:部署应用 v3 —— 仅从 `new_status` 读取
Day 7:迁移删除旧的 `status` 列| 反模式 | 为何会失败 | 更好的方法 | |-------------|-------------|-----------------| | 在生产中手动执行 SQL | 没有审计追踪,不可重复 | 始终使用迁移文件 | | 编辑已部署的迁移 | 导致环境间出现差异 | 改为创建新迁移 | | 没有默认值的 NOT NULL | 锁定表,重写所有行 | 添加可为空列,回填数据,然后添加约束 | | 在大表上内联创建索引 | 在构建期间阻塞写入 | 使用 CREATE INDEX CONCURRENTLY | | 在一个迁移中混合模式和数据的变更 | 难以回滚,事务时间长 | 分开的迁移 | | 在移除代码之前删除列 | 应用程序在缺失列时出错 | 先移除代码,下一次部署再删除列 |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-04 | pass→pass | 9,345 | 5,099 | -45% | 1 | 1 | 0% | 1,580 | 3,289 | +108% | 0 | 0 | — |
case-05 | pass→pass | 14,725 | 10,005 | -32% | 1 | 1 | 0% | 2,375 | 4,093 | +72% | 0 | 0 | — |
case-06 | pass→pass | 9,331 | 8,201 | -12% | 1 | 1 | 0% | 1,598 | 3,767 | +136% | 0 | 0 | — |
case-21 | pass→pass | 14,272 | 16,497 | +16% | 1 | 1 | 0% | 2,465 | 5,039 | +104% | 0 | 0 | — |
case-01 | pass→pass | 21,600 | 47,097 | +118% | 1 | 1 | 0% | 2,620 | 5,040 | +92% | 0 | 0 | — |
case-02 | pass→pass | 5,464 | 5,864 | +7% | 1 | 1 | 0% | 871 | 3,440 | +295% | 0 | 0 | — |
case-03 | pass→pass | 15,794 | 13,916 | -12% | 1 | 1 | 0% | 2,702 | 5,082 | +88% | 0 | 0 | — |
case-07 | pass→pass | 3,249 | 4,145 | +28% | 1 | 1 | 0% | 608 | 3,177 | +423% | 0 | 0 | — |
case-08 | pass→pass | 12,546 | 10,351 | -17% | 1 | 1 | 0% | 2,334 | 4,456 | +91% | 0 | 0 | — |
case-09 | pass→pass | 4,929 | 4,565 | -7% | 1 | 1 | 0% | 899 | 3,180 | +254% | 0 | 0 | — |
case-10 | pass→pass | 8,600 | 9,784 | +14% | 1 | 1 | 0% | 1,588 | 4,214 | +165% | 0 | 0 | — |
case-22 | pass→pass | 12,824 | 11,831 | -8% | 1 | 1 | 0% | 2,380 | 4,813 | +102% | 0 | 0 | — |
case-11 | pass→pass | 6,932 | 3,392 | -51% | 1 | 1 | 0% | 1,154 | 2,976 | +158% | 0 | 0 | — |
case-12 | pass→pass | 8,146 | 5,165 | -37% | 1 | 1 | 0% | 1,512 | 3,249 | +115% | 0 | 0 | — |
case-13 | pass→pass | 11,060 | 9,740 | -12% | 1 | 1 | 0% | 1,828 | 4,019 | +120% | 0 | 0 | — |
case-14 | pass→pass | 17,321 | 13,489 | -22% | 1 | 1 | 0% | 2,855 | 4,820 | +69% | 0 | 0 | — |
case-15 | fail→pass | 10,425 | 8,254 | -21% | 1 | 1 | 0% | 1,806 | 3,846 | +113% | 0 | 0 | — |
case-16 | pass→pass | 3,611 | 3,992 | +11% | 1 | 1 | 0% | 627 | 3,097 | +394% | 0 | 0 | — |
case-17 | pass→pass | 13,079 | 10,132 | -23% | 1 | 1 | 0% | 1,847 | 3,940 | +113% | 0 | 0 | — |
case-18 | pass→pass | 8,099 | 4,846 | -40% | 1 | 1 | 0% | 1,413 | 3,307 | +134% | 0 | 0 | — |
case-19 | pass→pass | 8,448 | 9,006 | +7% | 1 | 1 | 0% | 1,366 | 3,990 | +192% | 0 | 0 | — |
case-20 | pass→pass | 15,392 | 14,892 | -3% | 1 | 1 | 0% | 2,700 | 5,070 | +88% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.