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.
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_keyorchange_columnblocked writes on a large table. - Old processes failing with
PG::UndefinedColumnafter aremove_columnshipped. - A migration that sat waiting for a lock and froze a table because no
lock_timeoutwas 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.
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 Migrations — ignored_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.
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.