Writing safe migrations
RailsFast ships strong_migrations. In development and CI it refuses a migration that would lock or rewrite a busy production table, and prints the safe way to write it. The template's own docs/migrations.md is the short guide; this page covers what you meet first.
What the template configures
config/initializers/strong_migrations.rb sets:
| Setting | Value | Why |
|---|---|---|
start_after |
a timestamp in the template's history | Only migrations written after it are checked. Keep it: your migrations are dated after it. Bump it only to silence history you already ran in production, never to let a new migration through. |
lock_timeout |
10 seconds, retried 3 times, 10 seconds apart | A migration that cannot take its lock gives up instead of queueing every request behind it, and one unlucky attempt does not fail a deploy. |
statement_timeout |
1 hour | A statement that runs longer is a bug. |
remove_invalid_indexes |
true |
See below. |
auto_analyze |
true |
Fresh statistics after a new index, so Postgres does not pick bad query plans. |
target_version |
15 | The production database (the postgres:15 accessory), so development runs the checks that apply there. |
The rules you will meet
- Constraints are validated under a lock.
add_check_constraintblocks reads and writes while it checks every existing row, andadd_foreign_keyblocks writes on both tables. Add them withvalidate: falsein one migration, and validate them (validate_check_constraint,validate_foreign_key) in the next one. NOT NULLon an existing column:change_column_nullhas novalidate: false. Add aCHECK (column IS NOT NULL)constraint withvalidate: false, then in the next migration validate it, callchange_column_null, and remove the temporary constraint (Postgres uses the validated constraint to skip the full scan).- Indexes are built concurrently:
add_index ... algorithm: :concurrently, withdisable_ddl_transaction!in the migration. - Removing a column: add it to
self.ignored_columnsin the model, deploy, then remove it. - Renaming a column or changing its type: add the new column, backfill, switch the code, then drop the old one.
- Backfills go in their own migration or a job, in batches, never in the migration that changes the schema.
When a check fires on something you know is safe (a table that is always tiny, a brand-new table), wrap it in safety_assured { ... } with a comment that says why. That comment is the review.
Invalid indexes, and why the retry works
A CREATE INDEX CONCURRENTLY that runs out of its lock timeout on a busy table does not clean up after itself: it leaves an INVALID index behind under the name it was building. Postgres keeps updating that index but never uses it, and the retry then fails because the name is taken.
With remove_invalid_indexes = true (strong_migrations 2.8), strong_migrations drops such a leftover before each attempt to add the same index. That covers both its own retries and a later rerun of the migration, so a migration that failed on a busy table can simply be run again.
Migrations that look before they act
The template's own migrations for shared tables (push devices, pricing plan grants, organizations, Solid Queue batches) inspect the schema instead of trusting if_not_exists, which would accept an index left half-built by an interrupted run. Each step checks what is already there, so the migration is safe to rerun, does nothing on a database that already has the change, and adopts a table your app created earlier. Write your own migrations for busy tables the same way when they can be interrupted:
class AddReferenceIndexToOrders < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def up
existing = indexes(:orders).find { |index| index.columns == [ "reference" ] }
return if existing&.valid?
remove_index :orders, name: existing.name, algorithm: :concurrently if existing
add_index :orders, :reference, algorithm: :concurrently
end
def down
remove_index :orders, :reference, algorithm: :concurrently, if_exists: true
end
end
With no DDL transaction, Rails runs a migration that did not finish again on the next db:migrate, so every step must be safe to repeat.
Template migrations, not generators
Some gems in the template (pricing_plans, organizations) offer generators that write their own migrations. The template ships its own versions of those migrations, written for strong_migrations, and its update notes say when a generator must not be run. If a generator wrote a migration that strong_migrations refuses, delete it and use the template's. See Updating RailsFast for the release that introduced each one.