Database Migration
ActiveRecord migrations against PostgreSQL. Every migration must be reversible, tested, and safe
to run while the app is serving traffic.
What actually locks — the table people get wrong
Most "unsafe migration" folklore predates PostgreSQL 11 and is now wrong in the expensive
direction: it makes teams hand-roll batched backfills for operations that are already free, and
leaves them unprepared for the ones that aren't.
| Operation |
Rewrites the table? |
Postgres says |
ADD COLUMN with a constant default |
No |
"the default value is evaluated at the time of the statement and the result stored in the table's metadata… making the ALTER TABLE very fast even on large tables" |
ADD COLUMN with a volatile default (clock_timestamp()), a stored generated column, or an identity column |
Yes |
"will cause the entire table and its indexes to be rewritten" |
ALTER COLUMN TYPE |
Usually yes |
"normally cause the entire table and its indexes to be rewritten" — unless binary-coercible |
SET NOT NULL |
No rewrite, but a full scan |
"requires scanning the table to verify that existing rows meet the constraint" |
CREATE INDEX (non-concurrent) |
No rewrite, but blocks writes for the whole build |
— |
The lock is the risk, not the rewrite. A scan or a rewrite holds ACCESS EXCLUSIVE, which
blocks SELECT — and a migration waiting for that lock queues every query behind it. Every
migration therefore sets lock_timeout; that mechanism is owned by
../std-database/references/locking-and-timeouts.md and is not repeated here.
Pre-flight
- Table size. Under ~100k rows almost nothing here matters — don't build a two-phase
deploy for a lookup table. Over ~1M, assume every scan is an outage risk.
- Will it scan, rewrite, or neither? Use the table above. If you can't say, you're not
ready to write it.
- Dependencies: foreign keys, views, triggers, and Panko serializers referencing the column.
- Is the rollback real?
rails db:rollback must actually work, or the down is fiction.
- Backup verified restorable — not merely present.
Reversibility
Prefer change; ActiveRecord infers the inverse. When it cannot, be explicit rather than leaving
the rollback undefined:
class BackfillOrderStatus < ActiveRecord::Migration[7.1]
def up
Order.unscoped.in_batches(of: 5_000) { |batch| batch.update_all(status: "pending") }
end
def down
raise ActiveRecord::IrreversibleMigration # say it, don't leave `rails db:rollback` to fail at 2am
end
end
A data backfill is not reversible in general — the old NULLs are gone. Say so.
Expand and contract
For any change that would break a running instance, split it across deployments. The rule that
makes it work: at every step, both the old and the new code must run against the schema as it
exists. During a rolling deploy, they literally do.
Expand (backwards compatible) → add the new thing, backfill, write to both, deploy.
Contract (after every instance uses the new thing) → stop writing the old, drop it, deploy.
The most common failure is skipping the wait between phases. "All instances updated" is a fact
you verify, not a minute you count.
Separate schema from data
Schema migrations run in the deploy; backfills do not belong there. A db:migrate that
updates ten million rows holds the deploy hostage and times out the release.
- Small (<100k rows):
in_batches inside the migration is fine.
- Large: ship the backfill as a Sidekiq job or a rake task, run it after deploy, and make it
idempotent and resumable so a retry costs nothing.
Deployment checklist
Post-migration validation
- Confirm the schema change landed (
\d orders).
- Row counts unchanged (no accidental loss).
- App health: no
PG::UndefinedColumn in the logs (../log-search).
- Replication lag returned to normal.
- Query performance — a new index changes plans, sometimes for the worse.
Deep guides (read on demand, do not preload)
- Per-operation safe/unsafe with ActiveRecord: add/remove/rename column, add index, change type,
add a foreign key without the long lock,
NOT NULL without the scan, and batched backfills
→ references/migration-guide.md
- PostGIS spatial columns and JSONB patterns →
references/postgres-patterns.md
Related, owned elsewhere — do not duplicate: lock_timeout/statement_timeout, the lock queue,
disable_ddl_transaction! and advisory locks → ../std-database/references/locking-and-timeouts.md;
schema conventions, naming, and indexing rules → ../std-database.
1---2name: db-migration3description: Design PostgreSQL/PostGIS schemas and create safe ActiveRecord migrations with rollback plans, spatial column design, index strategy, and zero-downtime deployment patterns. Use this skill whenever someone asks to create a table, modify a schema, write a migration, design a data model, add an index, or says things like "create a migration for X", "add a column to Y", "design the database schema", "what indexes do I need", "plan the data model", or "how do I safely change this column type". Also trigger when someone mentions zero-downtime migrations, expand-and-contract pattern, backfills, PostGIS spatial columns, or large table migration strategy.4---56# Database Migration78ActiveRecord migrations against PostgreSQL. Every migration must be reversible, tested, and safe9to run while the app is serving traffic.1011## What actually locks — the table people get wrong1213Most "unsafe migration" folklore predates PostgreSQL 11 and is now wrong in the expensive14direction: it makes teams hand-roll batched backfills for operations that are already free, and15leaves them unprepared for the ones that aren't.1617| Operation | Rewrites the table? | Postgres says |18|---|---|---|19| `ADD COLUMN` with a **constant** default | **No** | *"the default value is evaluated at the time of the statement and the result stored in the table's metadata… making the `ALTER TABLE` very fast even on large tables"* |20| `ADD COLUMN` with a **volatile** default (`clock_timestamp()`), a stored generated column, or an identity column | **Yes** | *"will cause the entire table and its indexes to be rewritten"* |21| `ALTER COLUMN TYPE` | **Usually yes** | *"normally cause the entire table and its indexes to be rewritten"* — unless binary-coercible |22| `SET NOT NULL` | No rewrite, but **a full scan** | *"requires scanning the table to verify that existing rows meet the constraint"* |23| `CREATE INDEX` (non-concurrent) | No rewrite, but blocks **writes** for the whole build | — |2425**The lock is the risk, not the rewrite.** A scan or a rewrite holds `ACCESS EXCLUSIVE`, which26blocks `SELECT` — and a migration *waiting* for that lock queues every query behind it. Every27migration therefore sets `lock_timeout`; that mechanism is owned by28`../std-database/references/locking-and-timeouts.md` and is not repeated here.2930## Pre-flight31321. **Table size.** Under ~100k rows almost nothing here matters — don't build a two-phase33 deploy for a lookup table. Over ~1M, assume every scan is an outage risk.342. **Will it scan, rewrite, or neither?** Use the table above. If you can't say, you're not35 ready to write it.363. **Dependencies**: foreign keys, views, triggers, and Panko serializers referencing the column.374. **Is the rollback real?** `rails db:rollback` must actually work, or the down is fiction.385. **Backup verified restorable** — not merely present.3940## Reversibility4142Prefer `change`; ActiveRecord infers the inverse. When it cannot, be explicit rather than leaving43the rollback undefined:4445```ruby46class BackfillOrderStatus < ActiveRecord::Migration[7.1]47 def up48 Order.unscoped.in_batches(of: 5_000) { |batch| batch.update_all(status: "pending") }49 end5051 def down52 raise ActiveRecord::IrreversibleMigration # say it, don't leave `rails db:rollback` to fail at 2am53 end54end55```5657A data backfill is **not** reversible in general — the old NULLs are gone. Say so.5859## Expand and contract6061For any change that would break a running instance, split it across deployments. The rule that62makes it work: **at every step, both the old and the new code must run against the schema as it63exists.** During a rolling deploy, they literally do.6465**Expand** (backwards compatible) → add the new thing, backfill, write to both, deploy.66**Contract** (after every instance uses the new thing) → stop writing the old, drop it, deploy.6768The most common failure is skipping the wait between phases. "All instances updated" is a fact69you verify, not a minute you count.7071## Separate schema from data7273Schema migrations run in the deploy; **backfills do not belong there**. A `db:migrate` that74updates ten million rows holds the deploy hostage and times out the release.7576- Small (<100k rows): `in_batches` inside the migration is fine.77- Large: ship the backfill as a **Sidekiq job** or a rake task, run it after deploy, and make it78 idempotent and resumable so a retry costs nothing.7980## Deployment checklist8182- [ ] Tested on a staging copy with production-like **row counts** (a fast migration on 1k rows tells you nothing)83- [ ] Rollback actually executed, not just written84- [ ] `lock_timeout` set (`../std-database/references/locking-and-timeouts.md`)85- [ ] Old and new application code both work against the intermediate schema86- [ ] Backfill split out of the migration if the table is large87- [ ] Backup verified restorable88- [ ] Post-migration validation query prepared8990## Post-migration validation91921. Confirm the schema change landed (`\d orders`).932. Row counts unchanged (no accidental loss).943. App health: no `PG::UndefinedColumn` in the logs (`../log-search`).954. Replication lag returned to normal.965. Query performance — a new index changes plans, sometimes for the worse.9798## Deep guides (read on demand, do not preload)99100- Per-operation safe/unsafe with ActiveRecord: add/remove/rename column, add index, change type,101 add a foreign key without the long lock, `NOT NULL` without the scan, and batched backfills102 → `references/migration-guide.md`103- PostGIS spatial columns and JSONB patterns → `references/postgres-patterns.md`104105Related, owned elsewhere — do not duplicate: `lock_timeout`/`statement_timeout`, the lock queue,106`disable_ddl_transaction!` and advisory locks → `../std-database/references/locking-and-timeouts.md`;107schema conventions, naming, and indexing rules → `../std-database`.