Ruby

Strong Migrations for Rails: Safe Database Migration Practices (Rails 8.1)

Strong Migrations for Rails: Safe Database Migration Practices (Rails 8.1)

strong_migrations is a Rails gem that intercepts schema-changing calls inside a migration and aborts the ones that lock a busy table or break the running app: removing or renaming columns, changing types, non-concurrent indexes, NOT NULL, and check constraints. It prints the safer sequence instead; safety_assured skips a check you have reviewed. It supports PostgreSQL, MySQL, and MariaDB.

Every message in this guide was reproduced with Strong Migrations 2.8.0 on Rails 8.1.3.1 and PostgreSQL 17.11, and SQLite behavior is noted where it differs. If the gem has stopped one of your migrations, jump to what the message means. For how a migration runs at all (schema_migrations, transactions, rollbacks), see how Rails migrations work.

Which Migration Operations Are Dangerous?

A schema migration changes a table, column, index, or constraint. In development, where tables hold a few hundred rows, every one of them finishes in milliseconds. In production, the same statement meets millions of rows and a stream of concurrent queries, and two distinct things can go wrong.

Strong Migrations classifies an operation as dangerous when it either:

  • Blocks reads or writes for more than a few seconds once it has acquired its lock. Rewriting a table to change a column type, or building an index without CONCURRENTLY on PostgreSQL, holds that lock for as long as the work takes, and every query on the table queues behind it.
  • Has a good chance of raising errors in the running application. Removing or renaming a column that deployed code still reads is the classic case: Active Record caches the column list per process, so the app keeps generating SQL for a column the database no longer has.

Which operations fall into those classes depends on the database. This is what Strong Migrations 2.8.0 checks:

DatabaseOperations checked
AllRemoving a column, changing a column’s type, renaming a column or a table, create_table with force: true, adding an auto-incrementing or stored generated column, adding a foreign key, adding a check constraint, raw execute and change_table
PostgreSQL onlyAdding an index non-concurrently, add_reference (its index), unique and exclusion constraints, a json column, a column with a volatile default, NOT NULL on an existing column, renaming an enum value or a schema
MySQL and MariaDB onlyalgorithm: :copy, lock: :shared and lock: :exclusive, a column with an expression default

Backfilling data is on the README’s list too, but as a practice rather than a check. The gem cannot see an update_all inside a migration, which is why Backfilling Data is a discipline you keep yourself.

The other half of the risk is ordering. A migration and the code that depends on it rarely ship at the same instant, and the typical failure looks like this:

  1. A developer adds a model and its table in one branch.
  2. The branch is merged and deployed.
  3. The new code boots before the migration has run.
  4. The first request that touches the model raises ActiveRecord::StatementInvalid, wrapping the database’s own error; on PostgreSQL that is PG::UndefinedTable: ERROR: relation "widgets" does not exist.

Rolling the deploy back does not undo a schema change that did run; that takes a reversible migration applied in the other direction. The fix is to decide, per operation, whether the schema or the code goes first.

How Can We Fix Problems with Schema Migrations?

The ordering rule has two halves, and they point in opposite directions:

  • Additions: schema first, then code. A new table or column must exist before any deployed code selects it, so the migration runs before, or as part of, the deploy that ships the code. Old code never notices an extra column.
  • Removals: code first, then schema. Stop reading and writing the column, deploy that, and only then drop it. Between the two deploys the column is unused but present, which is harmless.

Changing a column, whether its name or its type, is both at once, and a type change locks the table for the rewrite. The safe sequence is the one Strong Migrations prints whenever it stops a rename_column or a change_column:

  1. Create a new column.
  2. Write to both columns.
  3. Backfill data from the old column to the new column.
  4. Move reads from the old column to the new column.
  5. Stop writing to the old column.
  6. Drop the old column.

Each step is its own deploy, and the last one follows the removal rule. This is the expand-and-contract pattern, and it applies to any project once its tables hold data and users depend on the app.

The trouble with a checklist is that it lives in your head, and the migration that bites is the one written at the end of a long day. Linters take that kind of load off; Strong Migrations does the same for migrations. It wraps Active Record’s schema methods, refuses the dangerous form, and prints the safe one.

Getting Help from Strong Migrations

Strong Migrations describes itself in one line: catch unsafe migrations in development. Once installed, it:

  1. Detects potentially dangerous operations.
  2. Prevents them from running by default.
  3. Prints instructions for a safer way to do the same thing.

The mechanism is small. The gem prepends a module to ActiveRecord::Migration whose method_missing catches every schema call a migration makes (add_index, remove_column, change_column_null, and the rest). It runs the check registered for that method name, and only then hands the call to Active Record. A failed check raises before any SQL reaches the database. The gem officially supports PostgreSQL, MySQL, and MariaDB; what happens on SQLite has its own section.

Installation

Add the gem and generate its initializer:

Shell
bundle add strong_migrations
bin/rails generate strong_migrations:install

bundle add writes gem "strong_migrations", "~> 2.8" to the Gemfile and installs it in the same step. The generator creates config/initializers/strong_migrations.rb; on a PostgreSQL app it contains:

Ruby
# Mark existing migrations as safe
StrongMigrations.start_after = 20260910060053
 
# Set timeouts for migrations
# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user
StrongMigrations.lock_timeout = 10.seconds
StrongMigrations.statement_timeout = 1.hour
 
# Analyze tables after indexes are added
# Outdated statistics can sometimes hurt performance
StrongMigrations.auto_analyze = true
 
# Set the version of the production database
# so the right checks are run in development
# StrongMigrations.target_version = 18
 
# Add custom checks
# StrongMigrations.add_check do |method, args|
#   if method == :add_index && args[0].to_s == "users"
#     stop! "No more indexes on the users table"
#   end
# end
 
# Remove invalid indexes when rerunning migrations
# StrongMigrations.remove_invalid_indexes = true
 
# Make some operations safe by default
# See https://github.com/ankane/strong_migrations#safe-by-default
# StrongMigrations.safe_by_default = true

start_after is the install timestamp, in UTC. Every migration with an older version is treated as safe, which is what lets you add the gem to an existing project without touching its history. The two timeouts apply to the migration session only; the settings section covers them and the commented options.

=== Dangerous operation detected #strong_migrations ===

This is the message that brings most people to this page. Take a migration that renames users.address to location:

Ruby
class RenameUserAddress < ActiveRecord::Migration[8.1]
  def change
    rename_column :users, :address, :location
  end
end

bin/rails db:migrate prints:

text
== 20260910140027 RenameUserAddress: migrating ================================
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
 
=== Dangerous operation detected #strong_migrations ===
 
Renaming a column that's in use will cause errors
in your application. A safer approach is to:
 
1. Create a new column
2. Write to both columns
3. Backfill data from the old column to the new column
4. Move reads from the old column to the new column
5. Stop writing to the old column
6. Drop the old column
 
db/migrate/20260910140027_rename_user_address.rb:3:in 'RenameUserAddress#change'
Tasks: TOP => db:migrate
(See full trace by running task with --trace)

Reading it from the top:

  • == 20260910140027 RenameUserAddress: migrating is Rails announcing the migration. It is the last thing the migrator says before the check fires.
  • bin/rails aborted! and StandardError: An error has occurred, this and all later migrations canceled: come from Rails, not from the gem. The migrator wraps any exception raised inside a migration in this StandardError, and this and means the migration ran inside a transaction that has been rolled back. Handling failures in the migration mechanics guide covers that wrapper.
  • The === Dangerous operation detected #strong_migrations === banner marks the gem’s own message. What follows is the check’s explanation and the safer sequence, written with your table and column names wherever the check knows them.
  • db/migrate/20260910140027_rename_user_address.rb:3:in 'RenameUserAddress#change' is the line in your migration that made the call, in Ruby 3.4’s backtrace format.

Nothing was applied. The check raised before rename_column reached the database, so there is no -- rename_column(...) line in the output, bin/rails db:migrate:status still lists the migration as down, and the address column is untouched. Fix the migration and run db:migrate again.

Two variants of the banner exist. === Possibly dangerous operation #strong_migrations === is what execute and change_table produce, and it has its own section. === Custom check #strong_migrations === comes from a check you wrote yourself with add_check.

What Changed in Strong Migrations 2.x

If you last read about this gem when 1.x was current, the checks and messages in this guide differ in several ways. From the CHANGELOG shipped with 2.8.0:

  • 1.8.0 (March 2024) changed the remove-column advice to self.ignored_columns += [...], appending instead of overwriting, so a model with several ignored columns keeps all of them.
  • 2.0.0 (June 2024) dropped PostgreSQL before 12, MySQL before 8.0, MariaDB before 10.5, and Ruby before 3.1. A target_version older than that is refused outright; the message is in the settings section.
  • 2.1.0 added skip_database, the experimental remove_invalid_indexes, and the warning for unsupported adapters that SQLite users see on every run.
  • 2.2.1 improved the backfill instructions; the current form is in_batches(of: 10000) with a where(column: nil) filter, so a rerun skips rows that are already filled.
  • 2.3.0 checks change_column on columns that carry a check constraint (PostgreSQL).
  • 2.4.0 put remove_invalid_indexes in the generated initializer and added an experimental transaction_timeout.
  • 2.6.0 (April 2026) requires Ruby 3.3 and Active Record 7.2 or later, and checks algorithm: :copy and lock: options on MySQL and MariaDB.
  • 2.7.0 checks add_foreign_key and callable column defaults on MySQL and MariaDB.
  • 2.8.0 (May 2026) adds a check for rename_enum_value on PostgreSQL.

One 1.x option is redundant on current Rails: alphabetize_schema, which sorted the columns in db/schema.rb to keep them from flipping between developers. Active Record 8.1 dumps columns alphabetically on its own.

Adding a Column or a Table

create_table is safe: nothing queries a table that did not exist a moment ago. Adding a column without a default is safe for the same reason, and on PostgreSQL 11 and later so is a column with a constant default. The server records the default in the catalog (pg_attribute marks the column as having a missing value) instead of rewriting every row. Strong Migrations 2.8.0 lets that through on PostgreSQL 17:

Ruby
class AddPlanToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :plan, :string, default: "free"
  end
end
text
== 20260910140013 AddPlanToUsers: migrating ===================================
-- add_column(:users, :plan, :string, {default: "free"})
   -> 0.0033s
== 20260910140013 AddPlanToUsers: migrated (0.0103s) ==========================

A volatile default is different. gen_random_uuid() yields a different value per row, so PostgreSQL has to write every row, and the check stops it:

Ruby
class AddTokenToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :token, :uuid, default: "gen_random_uuid()"
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Adding a column with a volatile default blocks reads and writes while the entire table is rewritten.
Instead, add the column without a default value, then change the default.
 
class AddTokenToUsers < ActiveRecord::Migration[8.1]
  def up
    add_column :users, :token, :uuid
    change_column_default :users, :token, "gen_random_uuid()"
  end
 
  def down
    remove_column :users, :token
  end
end
 
Then backfill the existing rows in the Rails console or a separate migration with disable_ddl_transaction!.
 
class BackfillAddTokenToUsers < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def up
    User.unscoped.in_batches(of: 10000) do |relation|
      relation.where(token: nil).update_all("token = gen_random_uuid()")
      sleep(0.01)
    end
  end
end

Both migrations in that message run clean as printed; the second one is a backfill, and Backfilling Data explains why it is a separate migration with disable_ddl_transaction!. On SQLite the generic, pre-PostgreSQL-11 version of this check fires for any non-null default, constant or not; the SQLite section shows it.

The one create_table form the gem refuses is force: true, which drops an existing table of the same name before creating the new one:

text
=== Dangerous operation detected #strong_migrations ===
 
The force option will destroy existing tables.
If this is intended, drop the existing table first.
In any case, remove the force option.

Renaming a Column

A rename is a removal and an addition in one statement, and the deployed code is still reading the old name when it runs. The check prints the six-step replacement instead:

text
=== Dangerous operation detected #strong_migrations ===
 
Renaming a column that's in use will cause errors
in your application. A safer approach is to:
 
1. Create a new column
2. Write to both columns
3. Backfill data from the old column to the new column
4. Move reads from the old column to the new column
5. Stop writing to the old column
6. Drop the old column

rename_table gets the same treatment with tables in place of columns. If the column sits on a small, quiet table and you know nothing reads it, safety_assured { rename_column :users, :address, :location } runs the rename. That’s a decision you record in the migration, not a shortcut.

Removing a Column

Active Record reads a table’s columns once per process and caches them. Drop a column while the app is running and the cached list still names it. The app keeps generating SQL that references a column the database no longer has, and those statements raise until every process has restarted. The check refuses a bare remove_column:

Ruby
class RemoveAddressFromUsers < ActiveRecord::Migration[8.1]
  def change
    remove_column :users, :address, :string
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Active Record caches attributes, which causes problems
when removing columns. Be sure to ignore the column:
 
class User < ApplicationRecord
  self.ignored_columns += ["address"]
end
 
Deploy the code, then wrap this step in a safety_assured { ... } block.
 
class RemoveAddressFromUsers < ActiveRecord::Migration[8.1]
  def change
    safety_assured { remove_column :users, :address, :string }
  end
end

The message is tailored: the model name, the column, and the exact migration to write. The sequence, verified end to end:

First, tell Active Record to ignore the column, and deploy that change on its own:

Ruby
class User < ApplicationRecord
  self.ignored_columns += ["address"]
end

Then write the migration with safety_assured, deploy it, and run it:

Ruby
class RemoveAddressFromUsers < ActiveRecord::Migration[8.1]
  def change
    safety_assured { remove_column :users, :address, :string }
  end
end
text
== 20260910140134 RemoveAddressFromUsers: migrating ===========================
-- remove_column(:users, :address, :string)
   -> 0.0028s
== 20260910140134 RemoveAddressFromUsers: migrated (0.0097s) ==================

Finally, delete the ignored_columns line. remove_columns, remove_reference, and remove_timestamps go through the same check, with every affected column listed.

Changing a Column Type

Changing a column’s type rewrites the whole table. On PostgreSQL, reads and writes are blocked while that happens; on MySQL and MariaDB, writes are. The check stops it and prints the six steps:

Ruby
class ChangeUsersPriceToBigint < ActiveRecord::Migration[8.1]
  def change
    change_column :users, :price, :bigint
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Changing the type of an existing column blocks reads and writes
while the entire table is rewritten. A safer approach is to:
 
1. Create a new column
2. Write to both columns
3. Backfill data from the old column to the new column
4. Move reads from the old column to the new column
5. Stop writing to the old column
6. Drop the old column

Some type changes need no rewrite, and the gem knows the list. On PostgreSQL, the README’s table includes increasing or removing :limit on a string, string to text (and back, when the target has no :limit), increasing or removing :precision on datetime, time, and interval, increasing :precision at the same :scale on decimal and numeric, and cidr to inet. Those pass:

Ruby
class ChangeUsersNameToText < ActiveRecord::Migration[8.1]
  def up
    change_column :users, :name, :text
  end
 
  def down
    change_column :users, :name, :string
  end
end
text
== 20260910140113 ChangeUsersNameToText: migrating ============================
-- change_column(:users, :name, :text)
   -> 0.0024s
== 20260910140113 ChangeUsersNameToText: migrated (0.0168s) ===================

change_column is not reversible inside change, which is why that migration defines up and down. Anything outside the safe list gets the six steps, and the backfill in step 3 is where the type conversion happens, in batches.

Adding an Index Concurrently

On PostgreSQL, a plain CREATE INDEX takes a lock that blocks writes to the table for the whole build. On a large table that is minutes of failed inserts:

Ruby
class AddIndexOnUsersEmail < ActiveRecord::Migration[8.1]
  def change
    add_index :users, :email
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Adding an index non-concurrently blocks writes. Instead, use:
 
class AddIndexOnUsersEmail < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def change
    add_index :users, :email, algorithm: :concurrently
  end
end

The fix has two parts, and both matter. algorithm: :concurrently builds the index without blocking writes, and disable_ddl_transaction! turns off the transaction Rails wraps around each migration, because PostgreSQL refuses to build an index concurrently inside one. With both in place:

Ruby
class AddIndexOnUsersEmail < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def change
    add_index :users, :email, algorithm: :concurrently
  end
end
text
== 20260910140035 AddIndexOnUsersEmail: migrating =============================
-- add_index(:users, :email, {algorithm: :concurrently})
   -> 0.0045s
== 20260910140035 AddIndexOnUsersEmail: migrated (0.0138s) ====================

Rolling that migration back drops the index concurrently as well (-- remove_index(:users, :email, {algorithm: :concurrently})).

Leave out the macro and Strong Migrations is satisfied, but PostgreSQL is not:

Ruby
class AddIndexOnUsersEmail < ActiveRecord::Migration[8.1]
  def change
    add_index :users, :email, algorithm: :concurrently
  end
end
text
== 20260910140032 AddIndexOnUsersEmail: migrating =============================
-- add_index(:users, :email, {algorithm: :concurrently})
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
 
PG::ActiveSqlTransaction: ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block
db/migrate/20260910140032_add_index_on_users_email.rb:3:in 'AddIndexOnUsersEmail#change'

That one is PostgreSQL and Rails talking, not the gem; nothing was created, because the statement was refused before the build began. The migration mechanics guide’s entry for this error explains the DDL transaction that disable_ddl_transaction! switches off and what a failure looks like with and without it.

Two settings take the tedium out of this. StrongMigrations.safe_by_default = true rewrites a bare add_index (and remove_index, add_reference, add_foreign_key, add_check_constraint, and change_column_null) into the safe form as the migration runs. With it on, the first migration in this section, unchanged, executes as:

text
== 20260910140144 AddIndexOnUsersEmail: migrating =============================
-- add_index(:users, :email, {algorithm: :concurrently})
   -> 0.0132s
== 20260910140144 AddIndexOnUsersEmail: migrated (0.0235s) ====================

StrongMigrations.remove_invalid_indexes = true handles the aftermath of a lock timeout. An index build that hits lock_timeout leaves an invalid index behind, and the rerun would fail on the duplicate name. This option drops the invalid index before trying again.

Setting NOT NULL and Adding Check Constraints

change_column_null :users, :email, false makes PostgreSQL scan every row to prove none of them is null, holding a lock that blocks reads and writes while it does:

Ruby
class SetUsersEmailNotNull < ActiveRecord::Migration[8.1]
  def change
    change_column_null :users, :email, false
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
Instead, add a check constraint and validate it in a separate migration.
 
class SetUsersEmailNotNull < ActiveRecord::Migration[8.1]
  def change
    add_check_constraint :users, "email IS NOT NULL", name: "users_email_null", validate: false
  end
end
 
class ValidateSetUsersEmailNotNull < ActiveRecord::Migration[8.1]
  def up
    validate_check_constraint :users, name: "users_email_null"
    change_column_null :users, :email, false
    remove_check_constraint :users, name: "users_email_null"
  end
 
  def down
    add_check_constraint :users, "email IS NOT NULL", name: "users_email_null", validate: false
    change_column_null :users, :email, true
  end
end

The trick is a check constraint added with validate: false. It takes effect for new writes immediately, without scanning existing rows. A later migration validates it, which scans the table under a much weaker lock. It then sets NOT NULL (PostgreSQL uses the validated constraint as proof and skips a second scan) and drops the constraint. Both migrations in the message ran as printed; the second one’s output:

text
== 20260910140102 ValidateSetUsersEmailNotNull: migrating =====================
-- validate_check_constraint(:users, {name: "users_email_null"})
   -> 0.0036s
-- change_column_null(:users, :email, false)
   -> 0.0009s
-- remove_check_constraint(:users, {name: "users_email_null"})
   -> 0.0016s
== 20260910140102 ValidateSetUsersEmailNotNull: migrated (0.0160s) ============

A plain check constraint is the same story, minus the NOT NULL step:

Ruby
class AddPriceCheckToUsers < ActiveRecord::Migration[8.1]
  def change
    add_check_constraint :users, "price > 0", name: "price_check"
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Adding a check constraint key blocks reads and writes while every row is checked.
Instead, add the check constraint without validating existing rows,
then validate them in a separate migration.
 
class AddPriceCheckToUsers < ActiveRecord::Migration[8.1]
  def change
    add_check_constraint :users, "price > 0", name: "price_check", validate: false
  end
end
 
class ValidateAddPriceCheckToUsers < ActiveRecord::Migration[8.1]
  def change
    validate_check_constraint :users, name: "price_check"
  end
end

Both suggested migrations run clean on PostgreSQL 17 (-- add_check_constraint(:users, "price > 0", {name: "price_check", validate: false}), then -- validate_check_constraint(:users, {name: "price_check"})). On MySQL and MariaDB the README offers no safe form.

Adding a Reference or a Foreign Key

add_reference adds a column and, by default, an index. On PostgreSQL that index is the non-concurrent build that Adding an Index Concurrently stops. foreign_key: true adds a constraint that validates every row while blocking writes on both tables:

Ruby
class AddCompanyToUsers < ActiveRecord::Migration[8.1]
  def change
    add_reference :users, :company, foreign_key: true
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Adding a foreign key blocks writes on both tables. Instead, use:
 
class AddCompanyToUsers < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def change
    add_reference :users, :company, index: {algorithm: :concurrently}
  end
end
 
Then add the foreign key in separate migrations.

The suggested form builds the index concurrently and leaves the foreign key for later:

Ruby
class AddCompanyToUsers < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def change
    add_reference :users, :company, index: {algorithm: :concurrently}
  end
end
text
== 20260910140122 AddCompanyToUsers: migrating ================================
-- add_reference(:users, :company, {index: {algorithm: :concurrently}})
   -> 0.0061s
== 20260910140122 AddCompanyToUsers: migrated (0.0131s) =======================

Adding the foreign key on its own is checked too:

Ruby
class AddUsersCompanyForeignKey < ActiveRecord::Migration[8.1]
  def change
    add_foreign_key :users, :companies
  end
end
text
=== Dangerous operation detected #strong_migrations ===
 
Adding a foreign key blocks writes on both tables. Instead,
add the foreign key without validating existing rows,
then validate them in a separate migration.
 
class AddUsersCompanyForeignKey < ActiveRecord::Migration[8.1]
  def change
    add_foreign_key :users, :companies, validate: false
  end
end
 
class ValidateAddUsersCompanyForeignKey < ActiveRecord::Migration[8.1]
  def change
    validate_foreign_key :users, :companies
  end
end

The pattern is the one from the constraints section. validate: false adds the constraint for new rows without a scan, and validate_foreign_key checks the existing rows in a second migration. Both ran clean here (-- add_foreign_key(:users, :companies, {validate: false}), then -- validate_foreign_key(:users, :companies)).

=== Possibly dangerous operation #strong_migrations === (execute)

Raw SQL is opaque to the gem, so it refuses every execute and asks you to vouch for it:

Ruby
class ExecuteRawOnUsers < ActiveRecord::Migration[8.1]
  def change
    execute "UPDATE users SET price = price + 1"
  end
end
text
=== Possibly dangerous operation #strong_migrations ===
 
Strong Migrations does not support inspecting what happens inside an
execute call, so cannot help you here. Please make really sure that what
you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.

safety_assured is the answer, and since execute cannot be reversed automatically, the migration needs up and down:

Ruby
class ExecuteRawOnUsers < ActiveRecord::Migration[8.1]
  def up
    safety_assured { execute "UPDATE users SET price = price + 1" }
  end
 
  def down
    safety_assured { execute "UPDATE users SET price = price - 1" }
  end
end
text
== 20260910140118 ExecuteRawOnUsers: migrating ================================
-- execute("UPDATE users SET price = price + 1")
   -> 0.0024s
== 20260910140118 ExecuteRawOnUsers: migrated (0.0118s) =======================

change_table gets the same header, because the gem cannot see what the block will do:

text
=== Possibly dangerous operation #strong_migrations ===
 
Strong Migrations does not support inspecting what happens inside a
change_table block, so cannot help you here. Please make really sure that what
you're doing is safe before proceeding, then wrap it in a safety_assured { ... } block.

Use individual add_column and add_index calls instead, or wrap the block in safety_assured once you have checked every statement in it.

Backfilling Data

Several of the safe sequences end with “then backfill the existing rows”. This is the one dangerous pattern Strong Migrations cannot catch. The README says so, and this migration runs without a word of complaint:

Ruby
class AddCountryCodeToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :country_code, :string
    User.update_all country_code: "fr"
  end
end

It is still a bad migration. Rails runs it inside a transaction, so the lock taken by add_column is held until the UPDATE of every row finishes. A single update_all on a large table is also a long statement in its own right. The discipline is yours to keep: add the column in one migration. Backfill in a second one that runs outside the DDL transaction, in batches, with a pause between them.

Ruby
class AddCountryCodeToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :country_code, :string
  end
end
Ruby
class BackfillCountryCodeColumn < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def up
    User.unscoped.in_batches(of: 10000) do |relation|
      relation.where(country_code: nil).update_all country_code: "fr"
      sleep(0.01)
    end
  end
end

Line by line:

  • disable_ddl_transaction! runs the migration without the wrapping transaction, so each batch commits on its own and no lock outlives a batch. Handling failures explains what that transaction does when it is on.
  • unscoped ignores any default scope on the model, so every row is visited.
  • in_batches(of: 10000) walks the table by primary key in batches of 10,000; leave of: out and Active Record uses 1,000.
  • where(country_code: nil) skips rows that are already filled, which makes the migration safe to rerun after an interruption.
  • sleep(0.01) gives the database a breather between batches.

Both migrations ran on PostgreSQL 17, and User.pluck(:country_code) came back ["fr"]. A rake task or a console session works as well as a migration for this. Whichever you choose, estimate the row count first, and if you backfill with anything other than update_all, call User.reset_column_information first so the model sees the new column.

Adding Strong Migrations to an Existing Ruby on Rails Project

Nothing special happens to the migrations you already have. The generator writes StrongMigrations.start_after with the install timestamp, and every migration with an older version skips the checks entirely. This migration, versioned in 2020 and doing a bare remove_column, ran under Strong Migrations 2.8.0 without a message:

Ruby
class LegacyRemoveNameFromUsers < ActiveRecord::Migration[6.0]
  def change
    remove_column :users, :name, :string
  end
end
text
== 20200101000000 LegacyRemoveNameFromUsers: migrating ========================
-- remove_column(:users, :name, :string)
   -> 0.0033s
== 20200101000000 LegacyRemoveNameFromUsers: migrated (0.0113s) ===============

So a fresh checkout of a project that installed the gem last year still sets itself up with bin/rails db:prepare, and history doesn’t need rewriting. If you install the gem by hand rather than with the generator, set start_after to your newest migration’s version yourself. Otherwise, the next db:migrate on a new machine stops at the first old remove_column.

Deleting old migration files is a separate, optional piece of housekeeping. New environments load db/schema.rb (or db/structure.sql) rather than replaying migrations, so once every environment is past a migration, the file can go. The migration mechanics guide and the Rails guide cover the mechanics. It lightens setup and keeps the db:migrate:status list readable, but Strong Migrations does not require it.

Timeouts, target_version, and Other Settings

Timeouts. The two lines the generator writes are the most valuable ones in the initializer. lock_timeout = 10.seconds means a migration that cannot get its lock within 10 seconds fails instead of sitting in the lock queue while every request behind it waits. statement_timeout = 1.hour gives a legitimately long statement room to finish. Both are set on the migration’s connection the first time a checked method runs. A migration that prints them after its first add_column:

Ruby
class ShowTimeoutsAfterDdl < ActiveRecord::Migration[8.1]
  def up
    add_column :users, :tmp_col, :string
    say "lock_timeout=#{connection.select_value("SHOW lock_timeout")} statement_timeout=#{connection.select_value("SHOW statement_timeout")}"
  end
 
  def down
    remove_column :users, :tmp_col
  end
end
text
== 20260910140139 ShowTimeoutsAfterDdl: migrating =============================
-- add_column(:users, :tmp_col, :string)
   -> 0.0032s
-- lock_timeout=10s statement_timeout=1h
== 20260910140139 ShowTimeoutsAfterDdl: migrated (0.0164s) ====================

The application’s own connections are untouched (the same SHOW from bin/rails runner returns 0 for both). Set those in config/database.yml, where PostgreSQL accepts them as session variables:

YAML
production:
  connect_timeout: 5
  variables:
    statement_timeout: 15s
    lock_timeout: 10s

If migrations run through PgBouncer in transaction mode, session variables do not stick. Delete the two lines from the initializer and set the timeouts on the database role instead:

SQL
ALTER ROLE myuser SET lock_timeout = '10s';
ALTER ROLE myuser SET statement_timeout = '1h';

target_version. Checks depend on the server version, and your development database is often newer than production. StrongMigrations.target_version = 16 makes the checks behave as they would on PostgreSQL 16; the major version is enough for PostgreSQL, while MySQL and MariaDB want major and minor. It applies in development and test only, and 2.8.0 refuses versions it no longer supports:

Ruby
StrongMigrations.target_version = 10
text
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
 
PostgreSQL version (10) not supported in this version of Strong Migrations (2.8.0)

With several databases, pass a hash keyed by configuration name, such as {primary: 16, analytics: 18}.

check_down. Checks are off when migrating down. Rolling back the timeouts migration runs its remove_column without a word:

text
== 20260910140139 ShowTimeoutsAfterDdl: reverting =============================
-- remove_column(:users, :tmp_col)
   -> 0.0027s
== 20260910140139 ShowTimeoutsAfterDdl: reverted (0.0138s) ====================

StrongMigrations.check_down = true turns them on for rollbacks too.

Disabling and adding checks. StrongMigrations.disable_check(:add_index) switches one check off by its key; with it in the initializer, the bare add_index from earlier runs as written (-- add_index(:users, :email)). The commented add_check block in the initializer shows the other direction. Uncommented, it stops any new index on users, and its output uses the third banner:

Ruby
StrongMigrations.add_check do |method, args|
  if method == :add_index && args[0].to_s == "users"
    stop! "No more indexes on the users table"
  end
end
text
== 20260910140150 AddIndexOnUsersEmail: migrating =============================
bin/rails aborted!
StandardError: An error has occurred, all later migrations canceled: (StandardError)
 
=== Custom check #strong_migrations ===
 
No more indexes on the users table

The custom check ran on the concurrent form of the migration, which is why the wrapper says all later migrations canceled without this and. disable_ddl_transaction! was in effect, so there was no transaction to roll back.

skip_database. StrongMigrations.skip_database(:analytics) turns the gem off for one of several databases, by its database.yml name.

lock_timeout_retries. Experimental: StrongMigrations.lock_timeout_retries = 3 retries a statement that hit lock_timeout, or the whole migration when it happened inside the DDL transaction, with lock_timeout_retry_delay between attempts.

auto_analyze. On by default in the generated initializer, it runs ANALYZE on the table after add_index and add_reference, so the planner has fresh statistics for the new index. It is also the setting behind the crash described in Strong Migrations on SQLite.

Strong Migrations on SQLite

Rails 8 generates new apps on SQLite, and Strong Migrations 2.8.0 does not support it. It does not refuse to load, either. Every db:migrate prints a warning per migration:

text
[strong_migrations] Unsupported adapter: SQLite. Use StrongMigrations.skip_database(:primary) to silence this warning.

Behind the warning the gem falls back to a generic adapter, and a subset of the checks still fires: removing a column, renaming a column, changing a column’s type, execute, change_table, and adding a column with any non-null default. The generic adapter cannot know that SQLite would not rewrite the table, so it flags this case too:

text
=== Dangerous operation detected #strong_migrations ===
 
Adding a column with a non-null default blocks reads and writes while the entire table is rewritten.
Instead, add the column without a default value, then change the default.
 
class AddPlanToUsers < ActiveRecord::Migration[8.1]
  def up
    add_column :users, :plan, :string
    change_column_default :users, :plan, "free"
  end
 
  def down
    remove_column :users, :plan
  end
end
 
Then backfill the existing rows in the Rails console or a separate migration with disable_ddl_transaction!.
 
class BackfillAddPlanToUsers < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def up
    User.unscoped.in_batches(of: 10000) do |relation|
      relation.where(plan: nil).update_all plan: "free"
      sleep(0.01)
    end
  end
end

The PostgreSQL-specific checks do not fire, so change_column_null and add_check_constraint run through unchecked:

text
== 20260910140022 SetUsersEmailNotNull: migrating =============================
[strong_migrations] Unsupported adapter: SQLite. Use StrongMigrations.skip_database(:primary) to silence this warning.
-- change_column_null(:users, :email, false)
   -> 0.0142s
== 20260910140022 SetUsersEmailNotNull: migrated (0.0147s) ====================

The problem is add_index. The DDL runs, and then auto_analyze calls a method the generic adapter does not have:

text
== 20260910140019 AddIndexOnUsersEmail: migrating =============================
[strong_migrations] Unsupported adapter: SQLite. Use StrongMigrations.skip_database(:primary) to silence this warning.
-- add_index(:users, :email)
   -> 0.0019s
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
 
undefined method 'analyze_table' for an instance of StrongMigrations::Adapters::AbstractAdapter
db/migrate/20260910140019_add_index_on_users_email.rb:3:in 'AddIndexOnUsersEmail#change'
 
Caused by:
NoMethodError: undefined method 'analyze_table' for an instance of StrongMigrations::Adapters::AbstractAdapter (NoMethodError)
 
        adapter.analyze_table(args[0])
               ^^^^^^^^^^^^^^
db/migrate/20260910140019_add_index_on_users_email.rb:3:in 'AddIndexOnUsersEmail#change'
Tasks: TOP => db:migrate
(See full trace by running task with --trace)

SQLite runs the migration inside a transaction, so the index is rolled back with it and the migration stays down. Every later db:migrate hits the same crash until the initializer changes. add_reference fails the same way, because it adds an index. Two fixes, both verified: set StrongMigrations.auto_analyze = false in the initializer, after which add_index runs and the warning stays; or add this line, which turns the gem off for that database, warning included:

Ruby
StrongMigrations.skip_database(:primary)

With skip_database in place, add_index and even a bare remove_column run as they would without the gem. The sensible arrangement for an app that develops on SQLite and deploys on PostgreSQL is to keep the checks for the PostgreSQL database and skip the SQLite one, and to set target_version so the checks match production.

Some Additional Advice on Migrations

Three habits round out what the gem enforces: think about the production database rather than the development one, deploy on any day of the week, and keep a backup you have tested.

Production Environments and Databases

A development database is rarely bursting at the seams. Most migrations run in milliseconds against it, which is why none of the operations in this guide look dangerous on a laptop. Production has more data, more varied data, and traffic competing for the same locks, and that is the environment every migration has to be written for. Batching and throttling a backfill, adding an index concurrently, and splitting a column change into steps all feel like overkill on a table with a hundred rows. Build the habit there, before the table has a hundred million.

Once a migration is running in production, watch two things: response time and throughput for the endpoints that hit the altered table, and the slow-query list. A table rewrite, or a lock queued behind a long transaction, appears as a sudden response-time spike with falling throughput, and the queries stuck behind it surface in AppSignal’s Slow Queries view sorted by impact — so you can tell a migration lock from an unrelated regression before the lock timeout expires.

Avoiding Downtime

Developers who avoid deploying late in the day or the week are avoiding the unknown, not the clock. A migration that has passed Strong Migrations, with a short lock timeout in place, has had its known failure modes removed before it reaches production. That is what makes deploying on a Friday afternoon a non-event rather than a gamble.

Backups

If something goes wrong anyway, a backup is the way out. Managed PostgreSQL and MySQL offerings take backups at regular intervals, and most provide point-in-time recovery, which can rewind the database to the minute before a migration ran. Restore one on a schedule to confirm that it works; an untested backup is a hope, not a plan.

Wrapping Up

Strong Migrations turns the expand-and-contract rules into checks that run before any SQL is sent, and its messages are written so that the fix can be copied straight out of the terminal. Install it, keep the lock timeout short, set target_version to match production, and treat safety_assured as a signature rather than a bypass.

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

What does the strong_migrations gem do in Rails?
It hooks into Active Record migrations and stops operations that block reads or writes on a busy table or break the running app: removing or renaming a column, changing its type, adding a non-concurrent index on PostgreSQL, and more. Each stop prints the safer migration to write instead.
How do I safely remove a column with strong_migrations?
First append the column name to self.ignored_columns in the model and deploy, so Active Record stops caching it. Then write the migration as safety_assured { remove_column :users, :address, :string }, deploy and run it, and finally delete the ignored_columns line.
Is adding a column with a default value safe in PostgreSQL?
Yes for a constant default on PostgreSQL 11 or later, which stores the default in table metadata without rewriting rows, and strong_migrations 2.8 lets it through. A volatile default such as gen_random_uuid() still rewrites the whole table, so add the column first and set the default in a second step.
How do I add an index without downtime in Rails on PostgreSQL?
Call disable_ddl_transaction! at the top of the migration and pass algorithm: :concurrently to add_index. Without the first line PostgreSQL refuses with CREATE INDEX CONCURRENTLY cannot run inside a transaction block; without the second, strong_migrations aborts the migration and prints the concurrent version.
Does strong_migrations work with SQLite?
Not officially. On SQLite it prints an unsupported adapter warning and falls back to generic checks: removing or renaming a column, changing a type, adding a column with a default, and raw execute. With the generated initializer, add_index crashes on auto_analyze, so call StrongMigrations.skip_database(:primary) or turn auto_analyze off.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Thomas Riboulet

Thomas Riboulet

Our guest author Thomas is a Consultant Backend and Cloud Infrastructure Engineer based in France. For over 13 years, he has worked with startups and companies to scale their teams, products, and infrastructure. He has also been published several times in France's GNU/Linux magazine and on his blog.

All articles by Thomas Riboulet

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal