LaunchKit
← All posts
· 18 min read · by The LaunchKit team · 1 views

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:

Dropping the legacy_token column splits into two deploy shapes: shipping the remove_column while old processes are still running makes every INSERT raise PG::UndefinedColumn, while shipping ignored_columns in a first deploy with no migration at all, letting every process restart, then dropping the column in a second deploy, leaves writes working throughout.

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.

#rails #active-record #postgresql

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.