
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
CONCURRENTLYon 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:
| Database | Operations checked |
|---|---|
| All | Removing 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 only | Adding 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 only | algorithm: :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:
- A developer adds a model and its table in one branch.
- The branch is merged and deployed.
- The new code boots before the migration has run.
- The first request that touches the model raises
ActiveRecord::StatementInvalid, wrapping the database’s own error; on PostgreSQL that isPG::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:
- Create a new column.
- Write to both columns.
- Backfill data from the old column to the new column.
- Move reads from the old column to the new column.
- Stop writing to the old column.
- 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:
- Detects potentially dangerous operations.
- Prevents them from running by default.
- 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:
bundle add strong_migrations
bin/rails generate strong_migrations:installbundle 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:
# 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 = truestart_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:
class RenameUserAddress < ActiveRecord::Migration[8.1]
def change
rename_column :users, :address, :location
end
endbin/rails db:migrate prints:
== 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: migratingis Rails announcing the migration. It is the last thing the migrator says before the check fires.bin/rails aborted!andStandardError: 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 thisStandardError, andthis andmeans 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_versionolder than that is refused outright; the message is in the settings section. - 2.1.0 added
skip_database, the experimentalremove_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 awhere(column: nil)filter, so a rerun skips rows that are already filled. - 2.3.0 checks
change_columnon columns that carry a check constraint (PostgreSQL). - 2.4.0 put
remove_invalid_indexesin the generated initializer and added an experimentaltransaction_timeout. - 2.6.0 (April 2026) requires Ruby 3.3 and Active Record 7.2 or later, and checks
algorithm: :copyandlock:options on MySQL and MariaDB. - 2.7.0 checks
add_foreign_keyand callable column defaults on MySQL and MariaDB. - 2.8.0 (May 2026) adds a check for
rename_enum_valueon 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:
class AddPlanToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :plan, :string, default: "free"
end
end== 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:
class AddTokenToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :token, :uuid, default: "gen_random_uuid()"
end
end=== 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
endBoth 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:
=== 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:
=== 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 columnrename_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:
class RemoveAddressFromUsers < ActiveRecord::Migration[8.1]
def change
remove_column :users, :address, :string
end
end=== 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
endThe 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:
class User < ApplicationRecord
self.ignored_columns += ["address"]
endThen write the migration with safety_assured, deploy it, and run it:
class RemoveAddressFromUsers < ActiveRecord::Migration[8.1]
def change
safety_assured { remove_column :users, :address, :string }
end
end== 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:
class ChangeUsersPriceToBigint < ActiveRecord::Migration[8.1]
def change
change_column :users, :price, :bigint
end
end=== 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 columnSome 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:
class ChangeUsersNameToText < ActiveRecord::Migration[8.1]
def up
change_column :users, :name, :text
end
def down
change_column :users, :name, :string
end
end== 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:
class AddIndexOnUsersEmail < ActiveRecord::Migration[8.1]
def change
add_index :users, :email
end
end=== 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
endThe 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:
class AddIndexOnUsersEmail < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def change
add_index :users, :email, algorithm: :concurrently
end
end== 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:
class AddIndexOnUsersEmail < ActiveRecord::Migration[8.1]
def change
add_index :users, :email, algorithm: :concurrently
end
end== 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:
== 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:
class SetUsersEmailNotNull < ActiveRecord::Migration[8.1]
def change
change_column_null :users, :email, false
end
end=== 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
endThe 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:
== 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:
class AddPriceCheckToUsers < ActiveRecord::Migration[8.1]
def change
add_check_constraint :users, "price > 0", name: "price_check"
end
end=== 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
endBoth 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:
class AddCompanyToUsers < ActiveRecord::Migration[8.1]
def change
add_reference :users, :company, foreign_key: true
end
end=== 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:
class AddCompanyToUsers < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def change
add_reference :users, :company, index: {algorithm: :concurrently}
end
end== 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:
class AddUsersCompanyForeignKey < ActiveRecord::Migration[8.1]
def change
add_foreign_key :users, :companies
end
end=== 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
endThe 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:
class ExecuteRawOnUsers < ActiveRecord::Migration[8.1]
def change
execute "UPDATE users SET price = price + 1"
end
end=== 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:
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== 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:
=== 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:
class AddCountryCodeToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :country_code, :string
User.update_all country_code: "fr"
end
endIt 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.
class AddCountryCodeToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :country_code, :string
end
endclass 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
endLine 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.unscopedignores 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; leaveof: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:
class LegacyRemoveNameFromUsers < ActiveRecord::Migration[6.0]
def change
remove_column :users, :name, :string
end
end== 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:
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== 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:
production:
connect_timeout: 5
variables:
statement_timeout: 15s
lock_timeout: 10sIf 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:
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:
StrongMigrations.target_version = 10bin/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:
== 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:
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== 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 tableThe 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:
[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:
=== 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
endThe PostgreSQL-specific checks do not fire, so change_column_null and add_check_constraint run through unchecked:
== 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:
== 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:
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?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

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 RibouletBecome our next author!
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!


