Rails migrations that do not break
Every migration in a new application is safe, and that is the problem. rails g model writes a
create_table, it runs in milliseconds against nothing, db/schema.rb updates, and the mechanism
looks like a formality. The first migration that hurts is the one that runs against a table with
rows in it, while processes are reading from that table, with a rollback that somebody is going to
need at two in the morning.
What goes wrong falls into four groups: a rollback that cannot run, an ALTER TABLE that blocks
more than it should, an index build that has to leave the transaction behind, and a deploy that puts
the schema ahead of the code. Each has a precise error message attached, and every one below was
reproduced against activerecord 8.1.3.1 and PostgreSQL 17.7, with the SQL and the timings copied out
of the run.
The commands change cannot undo
change does not run your method forwards and then backwards. It runs it through
ActiveRecord::Migration::CommandRecorder, which collects [method, args] pairs going up and
inverts each one going down. Inversion is a lookup: inverse_of builds :"invert_#{command}" and
raises if no such method exists.
raise IrreversibleMigration, <<~MSG unless respond_to?(method, true)
This migration uses #{command}, which is not automatically reversible.
To make the migration reversible you can either:
1. Define #up and #down methods in place of the #change method.
2. Use the #reversible method to define reversible behavior.
MSG
Three commands produce that in ordinary code. execute, because a SQL string has no inverse.
change_column, because the old type is not recorded anywhere. And remove_column without a type,
which gets its own sentence: "remove_column is only reversible if given a type." from
command_recorder.rb:242. Write remove_column :users, :name, :string and the rollback is an
add_column; write remove_column :users, :name and it is a ActiveRecord::IrreversibleMigration.
The detail that matters at 2am is when the raise happens. Inversion runs over the whole recorded
list before a single statement is sent, so a change that adds a column, adds an index and then
calls execute rolls back none of the three:
after up, trial_ends_on present? true
down raised ActiveRecord::IrreversibleMigration
after failed down, trial_ends_on still present? true
index still present? true
Nothing partial, nothing half-applied. The rollback simply does not start, which is the good outcome and also the confusing one, because the column you were trying to remove is still there and the error names a line three commands away from it.
Two ways to make it reversible
reversible takes a block and hands it a direction object, so the irreversible part gets an
explicit inverse while the rest of the change keeps its automatic one:
class AddStatusToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :status, :string
reversible do |dir|
dir.up { execute "UPDATE users SET status = 'active'" }
dir.down { execute "UPDATE users SET status = NULL" }
end
change_column_default :users, :status, from: nil, to: "active"
end
end
Both directions run clean. Note change_column_default taking from: and to: rather than a bare
value: without both it raises "change_column_default is only reversible if given a :from and :to
option.", which is the same family of message and the same fix.
The other option is to stop using change and write up and down, and for a data backfill that
is usually the better one. A reversible migration is not a goal in itself. A backfill that overwrote
a column has no honest inverse, and writing a down that pretends otherwise is worse than
raise ActiveRecord::IrreversibleMigration, "Can't recover the deleted tags", which the framework
documents as the thing to do when the answer is genuinely no.
What a defaulted column costs on two million rows
Adding a defaulted column is the question with the most folklore attached, and on PostgreSQL the folklore has been wrong since 2018. Here is the measurement, on a 147 MB table of 2,000,000 rows:
ALTER TABLE "big" ADD "plan" character varying DEFAULT 'free' NOT NULL (0.4ms)
elapsed: 0.002s
relfilenode before: 173586
relfilenode after: 173586
No rewrite. relfilenode is unchanged, and pg_attribute explains why:
[{"attname" => "plan", "atthasmissing" => true, "attmissingval" => "{free}"}]
The manual states the rule directly: "From PostgreSQL 11, adding a column with a constant default
value no longer means that each row of the table needs to be updated when the ALTER TABLE
statement is executed. Instead, the default value will be returned the next time the row is
accessed, and applied when the table is rewritten, making the ALTER TABLE very fast even on large
tables."
Rails 8 does nothing about this, and there is nothing for it to do. add_column in the PostgreSQL
adapter is four lines that call super and then handle :comment. One ALTER TABLE goes out and
the server decides what it costs. The word "constant" is where the decision gets made:
ALTER TABLE "big" ADD "token" uuid DEFAULT gen_random_uuid() NOT NULL (4838.4ms)
relfilenode after: 173654
A volatile default has to be evaluated per row, so the table is rewritten, the relfilenode moves,
and an ACCESS EXCLUSIVE lock is held for the whole 4.8 seconds. The rewrite also flipped plan's
atthasmissing back to false, because a rewrite materialises every missing value it finds.
So the rule is not "never add a column with a default". It is "a literal is free, a function call is
a table rewrite", and the Ruby that distinguishes them is default: "free" against
default: -> { "gen_random_uuid()" }.
The lock queue is the real cost
A 0.4ms statement can still take your site down, and the mechanism has nothing to do with the
statement. ALTER TABLE needs ACCESS EXCLUSIVE, PostgreSQL grants locks in arrival order, and a
waiter blocks everybody behind it.
Three sessions, started one second apart. Session A holds an open transaction that read big.
Session B runs the fast ALTER from the previous section. Session C is an ordinary request:
pid 9500 Client idle in transaction SELECT count(*) FROM big
pid 9508 Lock active ALTER TABLE big ADD COLUMN tier varchar DEFAULT 'free'
pid 9516 Lock active SELECT id FROM big WHERE id = 1
B: ALTER finished after 5.1s
C: plain SELECT of one row by primary key finished after 4.15s
A primary key lookup took four seconds. It was never going to be slow, it was never competing for
CPU, and nothing in the migration was expensive. It queued behind an ACCESS EXCLUSIVE request that
was queued behind somebody's forgotten transaction, and every request arriving during that window
did the same. That is what a migration outage looks like, and it is why the interesting number is
lock wait rather than statement duration.
The fix is one setting, and Active Record does not have it. Searching activerecord 8.1.3.1 for
lock_timeout returns nothing: there is no migration option, no configuration key, no default.
Setting it yourself turns a pile-up into a clean failure:
SET lock_timeout = '2s';
ActiveRecord::LockWaitTimeout
PG::LockNotAvailable: ERROR: canceling statement due to lock timeout
ActiveRecord::LockWaitTimeout with PG::LockNotAvailable as its cause. The migration aborts, the
deploy fails loudly, nothing queues, and you retry when the long transaction is gone. A failed
deploy is a much better outcome than four seconds added to every request for a minute.
Why concurrently needs the transaction turned off
Any migration adding an index to a table that already has traffic wants
algorithm: :concurrently, because a plain CREATE INDEX takes a SHARE lock and blocks every
write for the duration of the build. Writing it the obvious way fails:
class AddIndexNaive < ActiveRecord::Migration[8.1]
def change
add_index :big, :plan, algorithm: :concurrently
end
end
StandardError: An error has occurred, this and all later migrations canceled:
PG::ActiveSqlTransaction: ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
The manual is blunt about it: "A regular CREATE INDEX command can be performed within a
transaction block, but CREATE INDEX CONCURRENTLY cannot." The transaction is not yours. Migrator
wraps every migration in one when the adapter supports DDL transactions, and PostgreSQL does:
def ddl_transaction(migration, &block)
if use_transaction?(migration)
connection.transaction(&block)
else
yield
end
end
def use_transaction?(migration)
!migration.disable_ddl_transaction && connection.supports_ddl_transactions?
end
disable_ddl_transaction! sets the flag that makes use_transaction? false. One line in the class
body, and the index builds.
What you give up is visible in the same file. record_version_state_after_migrating is called
inside ddl_transaction, so an ordinary migration that fails rolls back its DDL and its
schema_migrations row together, atomically. Without the transaction those two can disagree, and
the error message changes to say so: the transactional version reads "this and all later migrations
canceled", the non-transactional one drops the "this and". Two words, and they are telling you that
this migration is partly applied.
The invalid index a failed build leaves behind
Here is the failure, and it is the section worth remembering. A unique index built concurrently against data that is not unique:
class AddUniqueIndex < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def change
add_index :big, :plan, unique: true, name: "index_big_on_plan_unique", algorithm: :concurrently
end
end
StandardError: An error has occurred, all later migrations canceled:
PG::UniqueViolation: ERROR: could not create unique index "index_big_on_plan_unique"
DETAIL: Key (plan)=(free) is duplicated.
Now look at what is in the database:
{"index_name" => "index_big_on_plan", "indisvalid" => true, "indisunique" => false}
{"index_name" => "index_big_on_plan_unique", "indisvalid" => false, "indisunique" => true}
schema_migrations => ["20260924000002"]
The index exists with indisvalid = false. PostgreSQL describes exactly this: "the CREATE INDEX
command will fail but leave behind an 'invalid' index. This index will be ignored for querying
purposes because it might be incomplete; however it will still consume update overhead." So the
object is pure cost. It slows down every INSERT and UPDATE on the table and serves no query.
The version row was never written, which is correct and also sets the trap. Fix the duplicate data,
run db:migrate again, and:
ActiveRecord::StatementInvalid: PG::DuplicateTable: ERROR: relation "index_big_on_plan_unique" already exists
The retry cannot succeed until somebody runs DROP INDEX CONCURRENTLY index_big_on_plan_unique by
hand. That is the recovery the manual recommends, and no Rails command will do it for you.
Then there is the part that outlives the incident. Dump the schema in that state:
t.index ["plan"], name: "index_big_on_plan_unique", unique: true
The schema dumper reads pg_index and does not look at indisvalid. It wrote the broken index into
schema.rb as a healthy unique index, and it did not write algorithm: :concurrently either,
because that is a build strategy rather than a property of the index. Load that file into a test
database and you get a valid unique constraint that production does not have. Every spec passes
against a schema production is not running.
add_reference is three statements
add_reference :comments, :post, foreign_key: true reads like one thing. It is three, and they have
three different lock profiles:
ALTER TABLE "comments" ADD "post_id" bigint (1.2ms)
CREATE INDEX "index_comments_on_post_id" ON "comments" ("post_id") (73.6ms)
ALTER TABLE "comments" ADD CONSTRAINT "fk_rails_2fd19c0db7" (24.2ms)
The column is instant. The index is a plain CREATE INDEX, blocking writes for its whole duration.
The foreign key scans both tables to validate every existing row while holding SHARE ROW
EXCLUSIVE, which blocks writes on both sides.
The last one splits in two, and the split is the whole technique. On 500,000 rows:
one step: ADD CONSTRAINT 156.6ms ShareRowExclusiveLock
two step: ADD CONSTRAINT ... NOT VALID 0.8ms ShareRowExclusiveLock
VALIDATE CONSTRAINT 96.0ms ShareUpdateExclusiveLock
Same total work, completely different exposure. validate: false gets the constraint in place and
enforced for new rows in under a millisecond of blocking. validate_foreign_key then scans the
existing rows under SHARE UPDATE EXCLUSIVE, which conflicts with DDL and with nothing your
application does.
The usual advice at this point is to abandon add_reference and write the three statements by hand.
That advice is stale. Both options pass straight through the one-liner:
class AddPostToComments < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def change
add_reference :comments, :post,
index: { algorithm: :concurrently },
foreign_key: { validate: false }
end
end
ALTER TABLE "comments" ADD "post_id" bigint (1.7ms)
CREATE INDEX CONCURRENTLY "index_comments_on_post_id" ON "comments" ("post_id") (131.5ms)
ALTER TABLE "comments" ADD CONSTRAINT "fk_rails_2fd19c0db7" (2.2ms)
pg_index.indisvalid is true for the index and pg_constraint.convalidated is false for the
constraint, which is exactly the state you want. What you now owe is a second migration calling
validate_foreign_key :comments, :posts, and nothing in Rails reminds you: an unvalidated foreign
key enforces new rows silently and forever, so forgetting the follow-up leaves a constraint that
never checked the rows that were already there.
The index name you did not choose
add_reference and add_index both derive an index name when you do not supply one, and on a wide
composite index the name they derive is not the obvious one. The boilerplate's notifications
migration carries the result, pinned by hand:
add_index :notifications, [ :recipient_type, :recipient_id, :read_at ],
name: "idx_on_recipient_type_recipient_id_read_at_50191a301d"
That name is not a choice. index_notifications_on_recipient_type_and_recipient_id_and_read_at is
66 bytes, max_index_name_size is 62, and generate_index_name falls back to
"idx_on_" + columns plus the first 10 hex characters of the SHA256 of the long name.
OpenSSL::Digest::SHA256.hexdigest(...)[0, 10] on that string is 50191a301d. Writing the
generated name into the migration is the right call, because the fallback is an implementation
detail and a future Rails could change it under a schema that already exists.
The migration runs before the code that needs it
Both deploy paths in the boilerplate put the migration ahead of the new code. The Procfile has
release: bin/rails db:prepare db:load_solid_schemas, and a Heroku release phase completes before
any new dyno starts. The Docker path is bin/docker-entrypoint, which runs the same db:prepare
when a container boots, so during a rolling deploy the new container migrates while old containers
are still serving. The Kamal half of that sequence is
Deploying Rails with Kamal; what concerns a rails migration is the
window it opens.
Inside that window the database is ahead of at least one running process. Adding things is fine. Removing them is not, and the failure depends on a setting most people have never read.
An old process booted with legacy_token in its schema cache, then the column is dropped:
partial_inserts = true; INSERT succeeded
partial_inserts = false; ActiveRecord::StatementInvalid: PG::UndefinedColumn: ERROR: column "legacy_token" of relation "accounts" does not exist
Reads survive either way, because Active Record emits SELECT "accounts".* FROM "accounts" and the
star is resolved by the server. Writes are where the stale cache shows. With partial inserts the
INSERT only names attributes that were assigned, so a nil legacy_token is simply absent from the
statement and nothing notices. With partial inserts off, every column the process believes in goes
into the INSERT, including the one that no longer exists.
config.load_defaults 7.0 sets active_record.partial_inserts = false, and the boilerplate's
config/application.rb declares config.load_defaults 8.1. So on any current Rails application,
dropping a column breaks writes in every process that has not restarted yet.
Two deploys to drop one column
The fix is to split the work, and the first deploy contains no migration at all:
class Account < ApplicationRecord
self.ignored_columns += %w[legacy_token]
end
Deploy that, let every process restart, and the running code no longer believes in the column.
Deploy the remove_column after. ignored_columns exists for precisely this window and does
nothing else; it tells Active Record to leave the column out of the attribute set even though the
schema still has it.
The same shape covers the other direction. A column your new code requires has to arrive in a
migration that ships before the code reads it, which is why add_column with null: false and no
default is a straight failure on a table with rows:
PG::NotNullViolation: ERROR: column "plan" of relation "accounts" contains null values
Add it nullable, backfill, then add the constraint. Three steps, and the middle one is a data migration rather than a schema change.
Two containers migrating at once
Rolling deploys boot several containers, each entrypoint runs db:prepare, and they race. Active
Record already handles it with a PostgreSQL advisory lock:
MIGRATOR_SALT = 2053462845
def generate_migrator_advisory_lock_id
db_name_hash = Zlib.crc32(connection.current_database)
MIGRATOR_SALT * db_name_hash
end
The loser gets ActiveRecord::ConcurrentMigrationError, whose message is "Cannot run migrations
because another migration process is currently running." Note what happens after the winner
finishes: with_advisory_lock calls load_migrated immediately after acquiring, specifically to
re-read schema_migrations in case another process moved it while this one waited.
That race is genuinely solved, and it is the one item on this list you do not have to think about. The message is still worth knowing on sight, because a container that dies with it during a deploy looks alarming and is not.
What strong_migrations checks for you
Everything above is a rule you have to remember at review time, which is a bad place to keep rules. strong_migrations turns them into a development-time failure. Version 2.8.0 shipped 2026-05-14, MIT, 83.9 million downloads, by Andrew Kane, and it covers PostgreSQL, MySQL and MariaDB. Version 2.6.0 raised its floor to Ruby 3.3 and Active Record 7.2.
It catches the specific cases in this post: an index added without algorithm: :concurrently, a
remove_column with no ignored_columns deployed first (its own wording: "Active Record caches
database columns at runtime, so if you drop a column, it can cause exceptions until your app
reboots"), a column added with a callable default, a change_column that rewrites. It also supplies
the lock_timeout Active Record does not have:
StrongMigrations.lock_timeout = 10.seconds
StrongMigrations.statement_timeout = 1.hour
The escape hatch is safety_assured { remove_column :users, :some_column }, and that block is where
the gem earns or loses its keep. Used when you have genuinely checked, it is a signed statement in
the diff. Used because the check was in the way, it is a warning suppressor, and a codebase where
half the migrations are wrapped in it has bought nothing.
The call, and what would change it
Install strong_migrations on any application whose tables have production rows in them and whose deploys are rolling. The cost is one dependency and some friction on migrations that were fine, which is a fair price for turning "we forgot" into a red build.
Skip it on an application that is not deployed yet. The LaunchKit boilerplate is the example: it has
seventeen migrations, none of them use disable_ddl_transaction! or algorithm: :concurrently, no
model declares ignored_columns, and 20260709170242_add_foreign_keys.rb adds twelve foreign keys
in one fully validating migration. All of that is correct, because every one of them runs against an
empty database on db:prepare. Adding safety checks to a schema nobody has data in is theatre, and
the honest time to install the gem is the deploy after the first real customer.
Whichever way you go, set lock_timeout on the migration connection. It is the single highest
return line in this post, it costs nothing, and it converts the worst failure mode here from a
site-wide stall into a failed deploy.
What would change the recommendation: a lock_timeout option on Active Record's migrations, which
would remove the strongest single reason to reach for a gem, and a schema dumper that refused to
write an index with indisvalid = false, which would close the one failure here that survives the
incident and reaches your test suite.
The cost of that position is real. strong_migrations will block a migration you know is fine, on a
table of forty rows, at the exact moment you are trying to ship, and the answer will be to wrap it
in safety_assured and move on. Do that often enough and the block stops meaning anything. The gem
only works if the team treats a rejection as a question rather than an obstacle, which is a cultural
requirement dressed as a dependency.
What this post does not cover
MySQL, where none of the lock measurements transfer, where DDL is not transactional so
disable_ddl_transaction! is moot, and where the equivalent of a concurrent index is
ALGORITHM=INPLACE, LOCK=NONE. The schema.rb against structure.sql question, which deserves its
own post and only starts to matter once you have triggers, partial indexes with expressions, or
extensions. Multi-database migrations and the migrations_paths per connection, which the
boilerplate does use for the Solid schemas but which changes none of the mechanics above. And
upgrading across major versions, where the ActiveRecord::Migration[6.1] compatibility shims decide
what an old migration means, which is part of
Upgrading Rails 7 to Rails 8.
No timings from this machine should be read as timings for yours. The reproducible parts are the
relfilenode comparison, the indisvalid flag, the lock modes, and the error classes with their
message text. Run them on your own database before you believe any of it.
Comments
No comments yet. Be the first.