Rails Active Record Migrations

Rails made schema migrations mainstream: a Ruby DSL for create_table and add_column, reversible change methods, timestamped files, and a schema.rb snapshot that every developer loads. The same conveniences hide the operational details that decide whether a deploy is invisible or an incident. add_index blocks writes on PostgreSQL unless told otherwise, remove_column breaks every running process whose Active Record model still lists the column, change_column can rewrite a table under an exclusive lock, and a data update written as Model.update_all inside a migration runs as one enormous transaction. This part of ORM & Framework Migration Workflows turns those defaults into a safe routine for Rails teams on PostgreSQL and MySQL: which operations are dangerous, how the strong_migrations gem catches them in development, and which idioms — disable_ddl_transaction!, algorithm: :concurrently, validate: false, ignored_columns — make each change online.

The approach is the same one that applies to every framework, set out in Zero-Downtime Schema Evolution Patterns: additive changes first, destructive changes only after no running code depends on the old structure, and every lock bounded in time. Rails simply gives each of those rules a concrete spelling.

Rails Migration Methods and Their Risk (PostgreSQL) Matrix of common Rails migration methods, the SQL they run on PostgreSQL, the risk during a rolling deploy, and the safe alternative. Rails Migration Methods and Their Risk (PostgreSQL) Method SQL (PostgreSQL) Risk Safe form add_column (nullable) ADD COLUMN none as is add_index CREATE INDEX writes blocked for build algorithm: :concurrently + disable_ddl_transaction! remove_column DROP COLUMN running processes still select it ignored_columns first, drop next release change_column (type) ALTER COLUMN TYPE table rewrite, ACCESS EXCLUSIVE new column, backfill, swap add_foreign_key ADD CONSTRAINT … REFERENCES validates under lock on both tables validate: false, then validate_foreign_key change_column_null false SET NOT NULL full-table scan under lock check constraint, validate, then SET NOT NULL
Most unsafe Rails migrations have a one-keyword fix; the hard part is noticing — which is what strong_migrations is for.

Concept & Mechanism

A Rails migration is a Ruby class inheriting from ActiveRecord::Migration[x.y]. Its change method is recorded as a list of commands so Rails can invert it on rollback; up and down are used when inversion is ambiguous. On PostgreSQL, Rails wraps each migration in a transaction (DDL is transactional there), so a failure rolls the whole migration back and every lock it acquires is held until it completes. On MySQL, DDL commits implicitly, so a multi-statement migration that fails partway leaves earlier statements applied — the behaviour explained in avoiding implicit commits in MySQL DDL migrations.

Two Active Record behaviours matter as much as the SQL. First, models cache their column list: when a Rails process boots (or first touches a model), it reads the table’s columns and uses them in every INSERT and in SELECT *-style queries. A process that loaded the column list before a remove_column will keep referencing the dropped column until it restarts. self.ignored_columns tells Active Record to pretend a column does not exist, which is how you decouple code from a column before dropping it. Second, the schema dump — db/schema.rb or db/structure.sql — is regenerated after every migration and is what new environments load; it records the latest migration version, which is a frequent source of merge conflicts.

Why remove_column Breaks Running Processes Sequence between a running Puma worker, the migrate task and PostgreSQL. The worker cached the orders columns at boot, including legacy_code. The migration drops legacy_code. The worker's next insert names legacy_code and fails with UndefinedColumn until the worker restarts with the new code. Why remove_column Breaks Running Processes Puma worker (old) db:migrate PostgreSQL boot: load columns of orders cache includes legacy_code ALTER TABLE orders DROP COLUMN legacy_code INSERT INTO orders (…, legacy_code, …) PG::UndefinedColumn
Active Record's cached column list outlives the column; ignored_columns removes it from the cache one release before the drop.

A third behaviour is specific to how Rails talks to MySQL. Because MySQL cannot roll back DDL, Rails does not wrap MySQL migrations in a transaction at all, and a migration that performs three ALTER TABLE statements executes three independently committed changes. If the second fails, the first stays applied and schema_migrations does not record the version, so the next db:migrate reruns the whole file and fails on the first statement. Rails’ own if_not_exists: and if_exists: options on add_column, add_index and friends (Rails 6.1+ for most of them) make such reruns safe, and keeping one DDL statement per migration makes failures easy to reason about. For online behaviour on MySQL, add_index accepts algorithm: :inplace and lock: :none, which turn a silently blocking build into an explicit failure if the operation cannot run online.

The last piece of the mechanism is timing. db:migrate runs in a single Ruby process with one database connection, and it holds that connection’s locks for as long as the current migration’s transaction lasts. When migrations run as a release phase on a platform such as Heroku or as a Kubernetes pre-deploy job, the old release keeps serving throughout, so every lock the migration takes is contended by live traffic. That is why the same migration that is instant in CI can stall production: CI has no concurrent transactions to queue behind. Lock timeouts turn that invisible difference into a fast, retryable failure.

Choosing the Safe Form for a Rails Change Decision tree for a Rails schema change. If it adds something (column, index, constraint), use the additive safe form (nullable column, concurrent index, validate false). If it removes something, check whether running code still references it; if yes, ignore it first and remove next release; if no, remove with a lock timeout. If it changes a column type, use a new column with a backfill and swap. Choosing the Safe Form for a Rails Change Does the change only add structure? yes no Additive safe form: nullable, concurrently, validate: false Does running code still reference it? yes no ignored_columns first, remove next release Remove with lock_timeout
Almost every Rails schema change falls into one of three shapes, and each shape has a standard safe form.

Prerequisites & Decision Criteria

The procedure assumes a deploy in which db:migrate runs once, before new application processes start, while old processes keep serving. Check the following before adopting it.

Requirement Why How
strong_migrations gem in the Gemfile flags unsafe operations when the migration runs in development and CI bundle add strong_migrations then rails generate strong_migrations:install
Lock and statement timeouts for migrations prevents a waiting DDL from freezing a table StrongMigrations.lock_timeout / statement_timeout in the initializer
Migrations run as a release phase concurrent db:migrate from many dynos or pods races release command or pre-deploy job
Rails 6.1+ add_check_constraint with validate: false, validate_check_constraint rails -v
PostgreSQL 12+ for NOT NULL via check constraint SET NOT NULL skips the scan when a valid check exists SHOW server_version

Review checklist for any Rails migration headed to production:

Step-by-Step Procedure

1. Install and configure strong_migrations. It intercepts dangerous operations when migrations run and explains the safe alternative, so problems surface on a developer’s machine rather than in production. Set timeouts in its initializer so every migration gets them. Verify by writing a deliberately unsafe migration and confirming it is rejected.

# Ruby · config/initializers/strong_migrations.rb · applies to db:migrate in all environments
# WARNING: timeouts apply to migration connections only, not to application queries.
StrongMigrations.lock_timeout = 5.seconds
StrongMigrations.statement_timeout = 1.hour
StrongMigrations.target_version = 16          # your PostgreSQL major version
StrongMigrations.start_after = 20260918000000 # skip checks for migrations already run

2. Add indexes concurrently. Disable the migration’s transaction and use the concurrent algorithm; keep nothing else in the file. The full treatment is in adding indexes concurrently in Rails.

3. Add constraints without validating, then validate separately. validate: false makes the constraint enforce new writes immediately while skipping the scan of existing rows; a later migration validates under a lock that does not block reads or writes.

# Ruby · two migrations · PostgreSQL · Rails 6.1+
# WARNING: run the validation migration only after the first has been deployed.
class AddOrdersCustomerFk < ActiveRecord::Migration[7.1]
  def change
    add_foreign_key :orders, :customers, validate: false
  end
end

class ValidateOrdersCustomerFk < ActiveRecord::Migration[7.1]
  def change
    validate_foreign_key :orders, :customers
  end
end
# ROLLBACK PATH: remove_foreign_key :orders, :customers

4. Remove columns in two releases. Release N adds the column to self.ignored_columns on the model; release N+1 drops it — see removing columns safely with ignored_columns.

5. Keep backfills out of schema migrations. Batch them with in_batches, pause between batches, and prefer a background job or a separate non-transactional migration, as described in backfilling data in Rails without locking.

6. Run db:migrate once as a release phase. Old processes continue serving against the updated schema, which is safe because every step above is backward compatible; new processes start after the migration succeeds.

Verification & Observability

Before deploying, check what will run and that the schema dump is consistent:

# Shell · CI job · test database · no production access needed
bin/rails db:migrate:status | tail -5
bin/rails db:migrate && git diff --exit-code db/schema.rb   # fails if the committed dump is stale

During the migration, watch the database, not the Rails log: pg_stat_activity for sessions waiting on locks and the lock tree from finding the blocking session with pg_blocking_pids. Afterwards, confirm schema_migrations contains the new versions and that no invalid indexes remain from a failed concurrent build.

-- PostgreSQL · read-only · after the release phase
SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 3;
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;

Two production signals are worth graphing permanently rather than checking by hand. The first is the duration of the release phase itself: a migration step that normally takes seconds and suddenly takes minutes usually means a lock wait or an unexpected table scan, and catching the trend early is cheaper than catching the incident. The second is the rate of PG::LockNotAvailable and PG::QueryCanceled errors from migration runs, which tells you how often lock_timeout is firing and therefore how contended your hot tables are during deploys. Both feed directly into the dashboards described in Migration Observability.

For teams with many developers, one more habit pays off: review migrations in isolation. A pull request that mixes a migration with a large feature diff makes the migration easy to skim past. Many Rails teams require schema migrations to land in their own small pull requests, reviewed by someone who owns the database, with the generated SQL — from bin/rails db:migrate output in CI or ActiveRecord::Migration.verbose logs — pasted into the description.

Rails Migration Pipeline Pipeline. strong_migrations runs the migration against the CI database and rejects unsafe operations; a gate checks schema.rb is up to date; the release phase runs db:migrate once with timeouts; a gate confirms schema_migrations and index validity; then new processes start. Rails Migration Pipeline CI db:migrate strong_migrations checks schema dump current? Release phase db:migrate once valid indexes valid? Start new processes rolling commit schema.rb halt, clean up fail
strong_migrations in CI plus a stale-schema check catch most problems before a production database is involved.

Rollback Path

Rails reverses migrations with bin/rails db:rollback (the latest) or db:migrate:down VERSION=... (a specific one), running the inverse of change or the down method. Schema reversal is reliable for additive changes; it is destructive for anything holding data, because reversing add_column drops the column and its contents.

# Shell · release tooling · production credentials
# WARNING: reversing add_column drops data written since the deploy; prefer rolling back code only.
bin/rails db:migrate:status
bin/rails db:migrate:down VERSION=20260918101500

Because each migration in the procedure is backward compatible, the usual rollback is of the application, not the schema: redeploy the previous release and leave the new column, index or unvalidated constraint in place. Reverse a migration only when it is additive and empty, or when it blocks the previous release — which the procedure is designed to prevent. The pipeline-level decision is covered in Rollback Automation.

Write down methods — or keep change reversible — anyway, because they matter in development and CI even when production rolls forward. A reversible migration lets developers switch branches cleanly with db:rollback, and a CI step that migrates up, down and up again catches down methods that no longer match their up. Mark genuinely irreversible migrations explicitly with raise ActiveRecord::IrreversibleMigration in down, so nobody discovers the fact during an incident.

Common Errors & Fixes

StrongMigrations::UnsafeMigration: Adding an index non-concurrently blocks writes. Root cause: add_index without algorithm: :concurrently. Fix: add disable_ddl_transaction! to the class and algorithm: :concurrently to the call.

PG::UndefinedColumn: ERROR: column orders.legacy_code does not exist after a deploy. Root cause: the column was removed while old processes still had it in their cached column list. Fix: re-add it as nullable to stop errors, then follow the ignored_columns sequence.

PG::LockNotAvailable: ERROR: canceling statement due to lock timeout. Root cause: the migration waited longer than lock_timeout for its lock, usually behind a long transaction. Fix: rerun — the transaction rolled back — and investigate the blocker if it recurs; see setting lock_timeout and retrying DDL safely.

Merge conflict in db/schema.rb on the define(version:) line. Root cause: two branches each ran migrations and wrote their latest version. Fix: take the higher version and regenerate the dump by running migrations, as described in resolving schema.rb merge conflicts.

Child Page Index

The guides under this topic cover the Rails-specific mechanics in depth. Using strong_migrations to catch unsafe DDL configures the gem and explains when safety_assured is legitimate. Adding indexes concurrently in Rails handles the transaction-free migration and invalid-index recovery. Removing columns safely with ignored_columns is the two-release removal. Backfilling data in Rails without locking covers in_batches, throttling and background jobs. And resolving schema.rb merge conflicts deals with the most common day-to-day friction of a busy Rails repository.

For comparison with other frameworks, the Django Migrations Without Downtime topic solves the same problems with Django’s operations, and the framework-neutral constraint patterns are in Adding Constraints Without Downtime.

Frequently Asked Questions

Does strong_migrations slow down migrations or change what they do? No. It inspects each migration operation as it runs and raises an error for known-dangerous patterns, and it sets the lock and statement timeouts you configure. Safe migrations run exactly as they would without it.

Why does Rails need disable_ddl_transaction! for concurrent indexes? PostgreSQL cannot run CREATE INDEX CONCURRENTLY inside a transaction, and Rails wraps each PostgreSQL migration in one by default. disable_ddl_transaction! turns off that wrapping for a single migration class.

Should I use schema.rb or structure.sql? schema.rb is database-agnostic and readable but cannot represent everything — partial indexes with complex predicates, triggers, custom types. If you use PostgreSQL-specific features, switch to structure.sql with config.active_record.schema_format = :sql.

Is change_column_null :orders, :region, false safe on a large table? Not directly: it runs SET NOT NULL, which scans the whole table under an exclusive lock. On PostgreSQL 12+, first add a check constraint region IS NOT NULL with validate: false, validate it, and then change_column_null completes without scanning.