DOCS LLMs

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_constraint blocks reads and writes while it checks every existing row, and add_foreign_key blocks writes on both tables. Add them with validate: false in one migration, and validate them (validate_check_constraint, validate_foreign_key) in the next one.
  • NOT NULL on an existing column: change_column_null has no validate: false. Add a CHECK (column IS NOT NULL) constraint with validate: false, then in the next migration validate it, call change_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, with disable_ddl_transaction! in the migration.
  • Removing a column: add it to self.ignored_columns in 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.