Using strong_migrations to Catch Unsafe DDL

Rails code review catches logic bugs well and migration hazards poorly. A reviewer sees add_index :orders, :customer_id and approves it; nothing in the diff says “this blocks writes on a 90-million-row table for six minutes”. The strong_migrations gem closes that gap by moving the review into the migration runner: when a migration executes — on a developer’s machine, in CI, or in production — each operation is checked against a catalogue of known-dangerous patterns, and dangerous ones raise an error that explains the safe alternative. This guide sets the gem up properly for a production Rails application, shows how to read its messages, adds project-specific checks, and sets rules for the escape hatch, safety_assured, which is where most teams quietly lose the protection. It is the first line of defence in Rails Active Record Migrations.

What Happens When a Migration Runs Sequence between a developer, db:migrate, strong_migrations and PostgreSQL. db:migrate asks strong_migrations to check each operation. For add_index without concurrently, strong_migrations raises UnsafeMigration with the safe alternative before any SQL reaches PostgreSQL. For a safe operation it sets lock_timeout and passes the SQL through. What Happens When a Migration Runs Developer db:migrate strong_migrations PostgreSQL bin/rails db:migrate add_index :orders, :customer_id UnsafeMigration + safe alternative no SQL was sent add_column :orders, :region, :string SET lock_timeout; ALTER TABLE
The check happens before the SQL is sent, so an unsafe migration fails on the developer's laptop instead of locking production.

Symptom / Error Signatures

You need this gem (or a stricter configuration of it) if your history includes any of these:

  • A deploy where add_index, add_foreign_key or change_column blocked writes on a large table.
  • Old processes failing with PG::UndefinedColumn after a remove_column shipped.
  • A migration that sat waiting for a lock and froze a table because no lock_timeout was set.

Once installed, its errors look like this, raised from db:migrate:

StrongMigrations::UnsafeMigration:
=== Dangerous operation detected #strong_migrations ===

Adding an index non-concurrently blocks writes. Instead, use:

class AddIndexOnOrdersCustomerId < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!

  def change
    add_index :orders, :customer_id, algorithm: :concurrently
  end
end

Root Cause Analysis

Rails’ migration DSL is database-neutral by design, so its methods emit the simplest correct SQL — which, on a busy PostgreSQL or MySQL table, is often the blocking form. The information needed to choose the safe form (table size, traffic, engine version, deploy model) is not in the migration file, so neither Rails nor a code reviewer can reliably spot the problem from the diff. strong_migrations encodes the knowledge as rules keyed on the operation and its options, and checks them at execution time.

Its main checks, and what each protects against:

Check Protects against Safe alternative it suggests
index added non-concurrently write block for the whole build algorithm: :concurrently + disable_ddl_transaction!
column removed old processes selecting the column ignored_columns first
column type changed table rewrite under exclusive lock new column + backfill (some widenings are marked safe)
foreign key added with validation lock on both tables during validation validate: false then validate_foreign_key
NOT NULL set on existing column full scan under exclusive lock check constraint, validate, then set
column added with volatile default table rewrite add, then backfill
column renamed or table renamed running code referencing old name expand and contract
data changes mixed with schema changes long transactions holding DDL locks separate migrations

Two configuration values make the checks accurate: target_version, so it knows which operations are safe on your database version (for example, adding a column with a default is safe on PostgreSQL 11+), and start_after, so it does not flag historical migrations that already ran.

Is safety_assured Justified? Decision tree for wrapping an operation in safety_assured. If the table is new in this same release and holds no production data, it is justified. Otherwise, if the operation has a documented safe alternative, use the alternative instead. If there is no alternative, require a written reason and a second reviewer. Is safety_assured Justified? Is the table new and empty in production? yes no Justified: add a comment saying so Does a safe alternative exist? yes no Use the alternative instead Written reason + second reviewer
safety_assured is for cases the gem cannot know are safe — not for making an inconvenient error go away.

Immediate Mitigation

1. Install the gem and generate the initializer.

# Shell · application repository · adds the gem and config/initializers/strong_migrations.rb
bundle add strong_migrations
bin/rails generate strong_migrations:install

2. Configure timeouts, the database version, and the starting point. Setting start_after to the latest applied migration version means the gem checks only new migrations.

# Ruby · config/initializers/strong_migrations.rb · loaded by every Rails process, applied to migrations only
# WARNING: lock_timeout applies to migration connections; application queries are unaffected.
StrongMigrations.start_after = 20260918000000
StrongMigrations.target_version = 16
StrongMigrations.lock_timeout = 5.seconds
StrongMigrations.statement_timeout = 1.hour
StrongMigrations.lock_timeout_retries = 3        # retry the migration on lock timeout
StrongMigrations.auto_analyze = true             # ANALYZE after adding an index

3. Run the full migration set in CI against a real database. The checks only fire when migrations execute, so CI must run db:migrate on a database of the same engine as production — not only db:schema:load.

# YAML · CI job step · PostgreSQL service container matching production's major version
- name: Run migrations with strong_migrations checks
  run: |
    bin/rails db:create
    bin/rails db:schema:load
    bin/rails db:migrate
  env:
    RAILS_ENV: test
    DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test

4. Audit existing safety_assured blocks. Search the migrations directory; every block should carry a comment explaining why it is safe. Treat unexplained ones as review debt.

Permanent Fix / Long-Term Pattern

Make the gem part of the definition of done for a migration. The rules that hold up in practice: every safety_assured block carries a comment and is approved by someone who understands the database; operations on tables created in the same release are the main legitimate use; and new categories of risk specific to your application become custom checks rather than tribal knowledge.

# Ruby · config/initializers/strong_migrations.rb · a project-specific check
# WARNING: custom checks run for every migration; keep them fast and precise.
HOT_TABLES = %w[orders payments sessions].freeze

StrongMigrations.add_check do |method, args|
  if %i[change_column_default change_column].include?(method) && HOT_TABLES.include?(args[0].to_s)
    stop! "Changing columns on #{args[0]} needs a DBA review; see the expand/contract runbook."
  end
end

strong_migrations checks operations, not outcomes. It cannot see that a data migration will take an hour, that a concurrent index will fail on duplicates, or that another service still reads a column. Pair it with the practices in Rails Active Record Migrationsignored_columns for removals, batched backfills, validation in separate migrations — and with pipeline-level checks such as gating migrations on estimated lock duration. Teams using other stacks get the same benefit from SQL-level linters, covered in Migration Linting & Static Analysis.

Where Unsafe Migrations Get Caught Stacked bars showing, for 100 unsafe migrations, where they were caught before and after adopting strong_migrations in CI. Before: 10 in code review, 90 in production. After: 8 in code review, 84 by strong_migrations in development or CI, 8 in production (mostly data-migration duration issues). Where Unsafe Migrations Get Caught before 10 90 after 8 84 8 code review strong_migrations production incident Illustrative distribution for 100 unsafe migrations.
The gem moves most detection to the developer's machine; what still reaches production is the class it cannot see — runtime and data volume.

Verification Checklist

Frequently Asked Questions

Does strong_migrations work with MySQL? Yes. Its checks apply to PostgreSQL, MySQL and MariaDB, with engine-specific rules — for example, it knows which MySQL operations can use online DDL. Set target_version to your MySQL or MariaDB version so the rules match.

Will it block migrations that already ran? Not if start_after is set to a version at or after the last applied migration. Only migrations with later versions are checked.

Can it retry a migration that hits lock_timeout? Yes, with lock_timeout_retries. It retries the migration (or, in migrations without a transaction, the individual statement) after a lock timeout, which pairs well with a short lock_timeout.

Is safety_assured ever the right answer for a production table? Occasionally — for example, a type change that the gem cannot recognise as safe on your version, or an operation you have measured as instant on a small table. Record the reasoning in a comment and have it reviewed; if a documented safe alternative exists, use that instead.