Ruby

Dissecting Rails Migrations: Anatomy, Rollbacks, and Errors (Rails 8.1)

Dissecting Rails Migrations: Anatomy, Rollbacks, and Errors (Rails 8.1)

A Rails migration is a timestamped Ruby class inheriting from ActiveRecord::Migration[8.1] whose change method alters the database schema. bin/rails db:migrate runs each pending migration once, records its version in the schema_migrations table, and dumps the result to db/schema.rb. Rollbacks invert change; irreversible steps need up and down. On PostgreSQL and SQLite, each migration runs inside a transaction.

Every command and error message here was run on Rails 8.1.3.1 against PostgreSQL 17.11; where SQLite or MySQL behaves differently, the text says so. If you landed here from a failing db:migrate, jump to the error reference.

Migrations 101

Migrations let you evolve the database over the lifetime of an application. Instead of hand-writing ALTER TABLE statements for one database engine, you describe the change in a small Ruby DSL (create_table, add_column, add_index, and friends). Active Record translates it into the SQL your adapter understands. When the DSL is not enough, execute sends raw SQL to the database.

Every Rails app keeps its migrations in db/migrate, one file per change, and Rails tracks which of those files have already run against each database. That bookkeeping is what makes it safe to run bin/rails db:migrate on every deploy: a migration that has already run is skipped.

Which of these operations are safe to run against a busy production table is a separate question, covered in our Strong Migrations guide. This page is about the mechanics: what a migration file contains, and how Rails runs, records, reverses, and fails one.

Anatomy of a Rails Migration

Start with a migration that creates an events table:

Shell
$ bin/rails generate migration CreateEvents category:string
      invoke  active_record
      create    db/migrate/20260910135840_create_events.rb

The generated file:

Ruby
class CreateEvents < ActiveRecord::Migration[8.1]
  def change
    create_table :events do |t|
      t.string :category
 
      t.timestamps
    end
  end
end

Four things are going on in this file:

  • The file name starts with a timestamp, 20260910135840. That number is the migration’s version, and Rails uses it to decide whether the migration has already run.
  • The class inherits from ActiveRecord::Migration[8.1]. The version in the brackets pins the migration to the migration API of the Rails release it was written for.
  • change holds the DSL. Here it creates an events table with a category column of type string.
  • t.timestamps adds created_at and updated_at columns.

bin/rails db:migrate runs it:

Shell
$ bin/rails db:migrate
== 20260910135840 CreateEvents: migrating =====================================
-- create_table(:events)
   -> 0.0025s
== 20260910135840 CreateEvents: migrated (0.0025s) ============================

In psql, \d events shows what the DSL turned into on PostgreSQL 17:

Shell
                                          Table "public.events"
   Column   |              Type              | Collation | Nullable |              Default
------------+--------------------------------+-----------+----------+------------------------------------
 id         | bigint                         |           | not null | nextval('events_id_seq'::regclass)
 category   | character varying              |           |          |
 created_at | timestamp(6) without time zone |           | not null |
 updated_at | timestamp(6) without time zone |           | not null |
Indexes:
    "events_pkey" PRIMARY KEY, btree (id)

Three details matter later: the primary key is a bigint, string became character varying (PostgreSQL’s varchar), and both timestamp columns are NOT NULL with microsecond precision (timestamp(6)), none of which the migration spelled out.

Migration Timestamps and the schema_migrations Table

The generator names each file with a timestamp in YYYYMMDDHHMMSS format, in UTC regardless of your machine’s time zone. With TZ=Asia/Kolkata set, a migration generated at 19:32 local time is still named 20260910140215_…. That number is the migration’s version.

When a migration runs, Rails inserts its version into a table it manages itself, schema_migrations. On PostgreSQL the table is nothing more than a single NOT NULL primary-key column:

SQL
CREATE TABLE public.schema_migrations (
    version character varying NOT NULL
);
ALTER TABLE ONLY public.schema_migrations
    ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);

After the first migration, select * from schema_migrations returns one row:

Shell
    version
----------------
 20260910135840
(1 row)

Rails creates a second bookkeeping table next to it, ar_internal_metadata, with a single environment row (development here). That row is what db:drop and db:reset check before doing anything destructive: mark a database as production and bin/rails db:drop refuses with ActiveRecord::ProtectedEnvironmentError: You are attempting to run a destructive action against your 'production' database. unless DISABLE_DATABASE_ENVIRONMENT_CHECK=1 is set.

Run bin/rails db:migrate a second time and nothing happens: every version in db/migrate is already in schema_migrations, so the command exits 0 without output. Generate another migration and the bookkeeping shows the gap:

Shell
$ bin/rails generate migration AddPriceToEvents price:integer
      invoke  active_record
      create    db/migrate/20260910135847_add_price_to_events.rb
$ bin/rails db:migrate:status

database: demo_development

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20260910135840  Create events
  down    20260910135847  Add price to events

down means the file exists but its version is not in the table. bin/rails db:version prints the newest applied version (Current version: 20260910135840 at this point), and after db:migrate both migrations show up.

schema.rb vs the Migration Files

After every successful db:migrate, Rails dumps the current schema to db/schema.rb. With both migrations applied:

Ruby
ActiveRecord::Schema[8.1].define(version: 2026_09_10_135847) do
  # These are extensions that must be enabled in order to support this database
  enable_extension "pg_catalog.plpgsql"
 
  create_table "events", force: :cascade do |t|
    t.string "category"
    t.datetime "created_at", null: false
    t.integer "price"
    t.datetime "updated_at", null: false
  end
end

Look at the column order: price was added by a later migration, yet it appears between created_at and updated_at. Active Record 8.1 sorts the columns of each table alphabetically when it dumps schema.rb (the CHANGELOG entry reads “The table columns inside schema.rb are now sorted alphabetically”). That removes a whole class of merge conflicts. The file describes the schema, not the order in which migrations built it.

schema.rb is what a fresh database gets. bin/rails db:schema:load creates every table in one pass and marks every migration up to version: as applied. That’s faster and less fragile than replaying years of migrations, where a migration that referenced a model or a gem that no longer exists fails on replay. Once every environment has run a migration, its file can be deleted; the Rails guide on old migrations describes the trade-off. Check schema.rb into version control.

Rails can also dump the schema as SQL (db/structure.sql), which captures database-specific objects such as triggers and functions that the Ruby format cannot express. The pros and cons of using structure.sql covers when to switch.

Rails Version in the Migration

The [8.1] in ActiveRecord::Migration[8.1] is a method call, and it returns the migration class for that Rails release:

Ruby
ActiveRecord::Migration[8.1] # => ActiveRecord::Migration::Current
ActiveRecord::Migration[8.0] # => ActiveRecord::Migration::Compatibility::V8_0
ActiveRecord::Migration[4.2] # => ActiveRecord::Migration::Compatibility::V4_2
ActiveRecord::Migration[9.0] # ArgumentError: Unknown migration version "9.0"; expected one of "4.2", "5.0", "5.1", "5.2", "6.0", "6.1", "7.0", "7.1", "7.2", "8.0", "8.1"

On Rails 8.1.3.1, Compatibility defines V4_2 through V8_1, each class inheriting from the next release up (V4_2 < V5_0 < … < V8_0 < V8_1). V8_1 is an alias for Current. The migration API was versioned in Rails 5.0 so it can change without breaking migrations written earlier. A migration pinned to [8.0] keeps behaving as it did on Rails 8.0 after you upgrade to 8.1. Each compatibility class undoes the changes of the release after it. V8_0, for example, sets _skip_column_match on remove_foreign_key, switching off a column check that 8.1 added.

The classic example is t.timestamps. This is the migration a Rails 4.2 app generated for the same kind of table (renamed so it can run next to events). Two details date it: the bare superclass, and the explicit null: false:

Ruby
class CreateLegacyEvents < ActiveRecord::Migration
  def change
    create_table :legacy_events do |t|
      t.string :category
 
      t.timestamps null: false
    end
  end
end

Rails 8.1 refuses to load it:

text
StandardError: Directly inheriting from ActiveRecord::Migration is not supported. Please specify the Active Record release the migration was written for: (StandardError)
 
  class CreateLegacyEvents < ActiveRecord::Migration[8.1]

The fix for an old migration is the pin it was written for, [4.2], and with it the compatibility class reproduces what Rails 4.2 did. Drop the null: false and run the same table under [4.2]:

Ruby
class CreateLegacyEvents < ActiveRecord::Migration[4.2]
  def change
    create_table :legacy_events do |t|
      t.string :category
 
      t.timestamps
    end
  end
end

\d legacy_events shows an integer primary key, timestamps that allow NULL, and no fractional-second precision. All three are what Rails 4.2 produced. Timestamps became NOT NULL by default in 5.0 (the V4_2 class sets null: true back), and later releases moved primary keys to bigint and gave timestamps microsecond precision. A migration keeps producing the schema it was written against, which is the whole point of the pin.

Changing the Database Schema

change is the method Rails calls when a migration runs. create_table is the most common call inside it; change_table alters a table that already exists:

Ruby
class ChangeEventsColumns < ActiveRecord::Migration[8.1]
  def change
    change_table :events do |t|
      t.remove :category, type: :string
      t.string :event_type
      t.boolean :active, default: false
    end
  end
end

This removes the category column, adds a string column event_type, and adds a boolean column active that defaults to false. The type: :string on t.remove is what makes the removal reversible: without it, bin/rails db:rollback aborts with remove_columns is only reversible if given a type., because Rails cannot recreate a column whose type it does not know. With the type, the rollback replays the three steps in reverse:

Shell
$ bin/rails db:rollback
== 20260910135905 ChangeEventsColumns: reverting ==============================
-- remove_column(:events, :active, :boolean, {default: false})
   -> 0.0020s
-- remove_column(:events, :event_type, :string)
   -> 0.0008s
-- add_columns(:events, :category, {type: :string})
   -> 0.0009s
== 20260910135905 ChangeEventsColumns: reverted (0.0055s) =====================

Other helpers you can call inside change include add_column, remove_column, change_column_default, change_column_null, add_index, remove_index, add_reference, add_foreign_key, and rename_table. The full list of methods change can reverse on its own is in the Rails guide.

Timestamps

t.timestamps adds created_at and updated_at. Active Record fills created_at when a record is inserted and bumps updated_at on every save, update, and touch. Both columns are NOT NULL, so a row written outside Active Record has to supply them.

One method skips that bookkeeping. update_all writes straight to the database and leaves updated_at untouched, while update! bumps it (with an Event model for the table):

Ruby
event = Event.create!(category: "meetup")
before = event.updated_at
Event.where(id: event.id).update_all(category: "conference")
event.reload.updated_at == before # => true
event.update!(category: "workshop")
event.reload.updated_at == before # => false

Reversible Migrations

bin/rails db:rollback reverts the most recent migration. For most migrations you never write the reverse yourself: when Rails rolls back a change method, it does not execute it against the database. It runs change against a command recorder, collects the calls (add_column, create_table, add_index, and so on), and then executes their inverses in reverse order. That is how the ChangeEventsColumns rollback knew to remove_column twice and add_columns once.

change vs up and down

The recorder only works for calls it can invert. Changing a column type is the standard counterexample: knowing that price became a string does not tell Rails what it was before. For those migrations, replace change with an up method (run on migrate) and a down method (run on rollback):

Ruby
class ChangeEventsPriceToString < ActiveRecord::Migration[8.1]
  def up
    change_table :events do |t|
      t.change :price, :string
    end
  end
 
  def down
    change_table :events do |t|
      t.change :price, :integer, using: "price::integer"
    end
  end
end

The using: option matters on PostgreSQL. It will not convert a character varying column to integer on its own, so a down that reads t.change :price, :integer fails with PG::DatatypeMismatch: ERROR: column "price" cannot be cast automatically to type integer (see the error reference). With the USING clause, this migration migrates and rolls back cleanly.

reversible and revert

reversible lets a change method carry explicit up and down behavior for one step while the rest stays automatic. The same type change written with reversible:

Ruby
class ChangeEventsPriceReversible < ActiveRecord::Migration[8.1]
  def change
    reversible do |direction|
      change_table :events do |t|
        direction.up { t.change :price, :string }
        direction.down { t.change :price, :integer, using: "price::integer" }
      end
    end
  end
end

revert runs another migration backwards. Given the CreateEvents migration from the start, this migration drops the table and recreates it with an extra column:

Ruby
require_relative "20260910135840_create_events"
 
class RecreateEvents < ActiveRecord::Migration[8.1]
  def change
    revert CreateEvents
 
    create_table :events do |t|
      t.string :category
      t.string :name
 
      t.timestamps
    end
  end
end

The require_relative is not optional. Migration files are loaded one at a time, so CreateEvents is not defined when RecreateEvents#change runs, and without the require the migration aborts with NameError: uninitialized constant RecreateEvents::CreateEvents. With it:

Shell
$ bin/rails db:migrate
== 20260910140128 RecreateEvents: migrating ===================================
-- drop_table(:events)
   -> 0.0065s
-- create_table(:events)
   -> 0.0058s
== 20260910140128 RecreateEvents: migrated (0.0168s) ==========================

Rolling it back replays both steps in reverse: the new table is dropped and CreateEvents runs forward again, so events comes back without name.

revert also takes a block and inverts whatever the block does. Read this one carefully, because migrating it up removes nothing: the block’s up branch is inverted, so the migration adds event_type, and rolling it back removes the column again:

Ruby
class AddEventTypeViaRevert < ActiveRecord::Migration[8.1]
  def change
    revert do
      reversible do |direction|
        change_table :events do |t|
          direction.up { t.remove :event_type }
          direction.down { t.string :event_type }
        end
      end
    end
  end
end

The block form exists for undoing part of an earlier migration without reverting the whole class; the reverting previous migrations section of the guide has more examples.

Rolling Back Migrations: STEP, VERSION, and redo

bin/rails db:rollback reverts one migration; STEP reverts several, newest first. With AddVenueToEvents and AddIndexOnEventsCategory as the two most recent migrations:

Shell
$ bin/rails db:rollback STEP=2
== 20260910135934 AddIndexOnEventsCategory: reverting =========================
-- remove_index(:events, :category)
   -> 0.0047s
== 20260910135934 AddIndexOnEventsCategory: reverted (0.0069s) ================

== 20260910135932 AddVenueToEvents: reverting =================================
-- remove_column(:events, :venue, :string)
   -> 0.0020s
== 20260910135932 AddVenueToEvents: reverted (0.0020s) ========================

db:migrate:status now lists both as down, and a plain bin/rails db:migrate applies them again.

VERSION targets a specific migration. bin/rails db:migrate VERSION=20260910135847 migrates to that version: every migration newer than 20260910135847 is reverted, newest first, and anything older that is still pending is applied. To run a single migration in one direction without touching its neighbors, use db:migrate:down and db:migrate:up:

Shell
bin/rails db:migrate:down VERSION=20260910135932
bin/rails db:migrate:up VERSION=20260910135932

db:migrate:redo rolls back and re-applies the last migration (or the last STEP=n), which is the quickest way to check that a down method works:

Shell
$ bin/rails db:migrate:redo
== 20260910135934 AddIndexOnEventsCategory: reverting =========================
-- remove_index(:events, :category)
   -> 0.0046s
== 20260910135934 AddIndexOnEventsCategory: reverted (0.0066s) ================

== 20260910135934 AddIndexOnEventsCategory: migrating =========================
-- add_index(:events, :category)
   -> 0.0038s
== 20260910135934 AddIndexOnEventsCategory: migrated (0.0039s) ================

Two things to know about the command names. There is no db:redo: bin/rails db:redo answers Unrecognized command "db:redo" (Rails::Command::UnrecognizedCommandError) and suggests db:drop, which is not what you want. And a VERSION that matches no file fails before touching the database, with ActiveRecord::UnknownMigrationVersionError: No migration with version number 123.. bin/rails db:version prints the current version, and bin/rails -T db lists every task, including the rollback options.

Handling Failures

Migrations fail: a typo in a table name, a constraint the existing rows violate, a lock that times out. What state the database is in afterwards depends on whether the migration ran inside a transaction.

Rails wraps a migration in a transaction when two conditions hold: the migration has not called disable_ddl_transaction!, and the adapter reports supports_ddl_transactions?. On Active Record 8.1.3.1, the PostgreSQL and SQLite adapters return true. The MySQL adapters inherit false from the abstract adapter, because MySQL commits DDL statements implicitly and cannot roll them back. On MySQL, every migration behaves as if the transaction were switched off.

Take a migration whose second statement fails because of a typo:

Ruby
class AddCityAndCountryToEvents < ActiveRecord::Migration[8.1]
  def change
    add_column :events, :city, :string
    add_column :evnts, :country, :string
  end
end

On PostgreSQL:

Shell
$ bin/rails db:migrate
== 20260910140224 AddCityAndCountryToEvents: migrating ========================
-- add_column(:events, :city, :string)
   -> 0.0027s
-- add_column(:evnts, :country, :string)
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

PG::UndefinedTable: ERROR:  relation "evnts" does not exist
db/migrate/20260910140224_add_city_and_country_to_events.rb:4:in 'AddCityAndCountryToEvents#change'

Caused by:
ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR:  relation "evnts" does not exist (ActiveRecord::StatementInvalid)
db/migrate/20260910140224_add_city_and_country_to_events.rb:4:in 'AddCityAndCountryToEvents#change'

Caused by:
PG::UndefinedTable: ERROR:  relation "evnts" does not exist (PG::UndefinedTable)
db/migrate/20260910140224_add_city_and_country_to_events.rb:4:in 'AddCityAndCountryToEvents#change'

The transaction rolled back the add_column :events, :city that had already succeeded: \d events shows no city column, db:migrate:status lists the migration as down, and schema_migrations did not get the version. Fix the typo, run db:migrate again, and the migration runs from the top. This is also what the transactions section of the guide means by a migration being atomic.

disable_ddl_transaction! switches the wrapper off for one migration. It is a class-level macro, called in the class body:

Ruby
class AddCityAndCountryToEvents < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def change
    add_column :events, :city, :string
    add_column :evnts, :country, :string
  end
end

Now the same typo leaves the database half-migrated. The wrapper line changes to An error has occurred, all later migrations canceled: (no “this and”), city stays on the table, the migration is still down, and rerunning it after the fix fails on the first line instead: PG::DuplicateColumn: ERROR: column "city" of relation "events" already exists. Recovery is manual: drop the column by hand, then run db:migrate again.

The macro has to sit in the class body, and the name is singular. The plural disable_ddl_transactions! is not a method at all, and the correct name called from inside change raises NoMethodError: undefined method 'disable_ddl_transaction!' for an instance of AddIndexOnEventsUserId; in both cases Ruby’s Did you mean? disable_ddl_transaction suggestion points at the reader method, not the macro. The main reason to switch the transaction off is PostgreSQL’s CREATE INDEX CONCURRENTLY, which builds an index without blocking writes and refuses to run inside a transaction block. The error reference shows the failure and the fix.

One more guard runs before any migration does. The migrator takes an advisory lock on the database (the lock ID is a fixed salt, 2053462845, multiplied by the CRC32 of the database name). A second db:migrate process that cannot get the lock exits with ActiveRecord::ConcurrentMigrationError instead of racing the first.

Which operations hold a lock long enough to hurt a running app, and how to sequence them, is the subject of the Strong Migrations guide.

Executing It Raw

When the DSL has no method for what you need (an expression index, a trigger, a backfill written in SQL), execute runs a SQL string:

Ruby
class AddLowerCategoryIndexToEvents < ActiveRecord::Migration[8.1]
  def change
    execute <<~SQL
      CREATE INDEX index_events_on_lower_category ON events (LOWER(category))
    SQL
  end
end

This migrates fine, and then db:rollback aborts: Rails has no way to invert an arbitrary SQL string, so a bare execute inside change raises ActiveRecord::IrreversibleMigration on the way down, and the migration stays up. Give the migration both directions, either with up and down or with reversible:

Ruby
class AddLowerCategoryIndexToEvents < ActiveRecord::Migration[8.1]
  def change
    reversible do |direction|
      direction.up do
        execute "CREATE INDEX index_events_on_lower_category ON events (LOWER(category))"
      end
      direction.down do
        execute "DROP INDEX index_events_on_lower_category"
      end
    end
  end
end
Shell
$ bin/rails db:rollback
== 20260910140048 AddLowerCategoryIndexToEvents: reverting ====================
-- execute("DROP INDEX index_events_on_lower_category")
   -> 0.0023s
== 20260910140048 AddLowerCategoryIndexToEvents: reverted (0.0041s) ===========

Raw SQL also bypasses every safety check. With Strong Migrations installed, a bare execute is refused (“Strong Migrations does not support inspecting what happens inside an execute call”) until you wrap it in safety_assured { … }. That’s the gem’s way of making you say you have checked.

Rails Migration Errors and How to Fix Them

Every message in this section is copied from a real bin/rails db:migrate or db:rollback on Rails 8.1.3.1 and PostgreSQL 17.11, with file paths shown relative to the application root. Errors prefixed PG:: are PostgreSQL’s own; the ActiveRecord:: ones apply to every adapter. Most of them arrive wrapped in the same StandardError line, so start with the Caused by: block.

ActiveRecord::PendingMigrationError: Migrations are pending

text
Migrations are pending. To resolve this issue, run:
 
        bin/rails db:migrate
 
You have 1 pending migration:
 
db/migrate/20260910135847_add_price_to_events.rb

Cause: A migration file exists whose version is not in schema_migrations. Three places raise it. In development, the ActiveRecord::Migration::CheckPending middleware (installed because the generated config/environments/development.rb sets config.active_record.migration_error = :page_load) turns every request into this error page. bin/rails test prints the same text and exits 1, because rails/test_help calls ActiveRecord::Migration.maintain_test_schema! at boot (config.active_record.maintain_test_schema defaults to true, so the generated test_helper.rb no longer mentions it). And ActiveRecord::Migration.check_all_pending! raises it from any code that calls it.

Fix: Run bin/rails db:migrate, then restart the server. In the test environment, the sequence is different. maintain_test_schema! first loads db/schema.rb into the test database when the schema file is newer than what the database has, and only then checks for pending migration files. A migration that has run in development and been dumped to schema.rb never triggers this in tests; one that has not run anywhere does. Migrate in development and commit the updated schema.rb.

ActiveRecord::IrreversibleMigration: This migration uses execute, which is not automatically reversible

text
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
 
 
 
This migration uses execute, 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.
 
 
db/migrate/20260910140048_add_lower_category_index_to_events.rb:3:in 'AddLowerCategoryIndexToEvents#change'
 
Caused by:
ActiveRecord::IrreversibleMigration:  (ActiveRecord::IrreversibleMigration)

Cause: db:rollback (or db:migrate:down, or db:migrate VERSION=) reached a change method containing a call the command recorder cannot invert, and the message names the call. The variants on 8.1.3.1:

  • This migration uses execute, which is not automatically reversible., and the same wording for change_column.
  • remove_column is only reversible if given a type., and remove_columns is only reversible if given a type. for t.remove inside change_table.
  • To avoid mistakes, drop_table is only reversible if given options or a block (can be empty).

Fix: Give Rails the missing information or the missing direction. remove_column :events, :venue, :string names the type so Rails can re-add the column. drop_table :widgets do |t| … end with the original column block lets Rails re-create the table. change_column and execute have no inverse at all, so replace change with up and down, or wrap the step in reversible. The failed rollback leaves the migration up (db:migrate:status still lists it), so after editing the file, run db:rollback again.

StandardError: An error has occurred, this and all later migrations canceled

text
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

Cause: This line is not the error. ActiveRecord::Migrator wraps whatever a migration raised in a StandardError with this prefix, and Rails prints the original exception under Caused by:. The wording tells you whether a transaction was in play. “This and all later migrations canceled” means the failed migration ran inside a DDL transaction and was rolled back. “All later migrations canceled”, without “this and”, means it ran with disable_ddl_transaction! or on an adapter without DDL transactions, and its earlier statements are still applied.

Fix: Read the Caused by: chain from the bottom up. The last entry is the database error (PG::UndefinedTable, PG::DatatypeMismatch, and so on), the middle one is Active Record’s wrapper (ActiveRecord::StatementInvalid), and the frame under each points at the line in your migration file. The rest of this reference is organized by that inner error.

PG::UndefinedTable: relation does not exist

text
PG::UndefinedTable: ERROR:  relation "evnts" does not exist
db/migrate/20260910140224_add_city_and_country_to_events.rb:4:in 'AddCityAndCountryToEvents#change'
 
Caused by:
ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR:  relation "evnts" does not exist (ActiveRecord::StatementInvalid)

Cause: The migration names a table that does not exist in this database: a typo (evnts), a table an earlier migration dropped, or a migration ordered before the one that creates the table. A merged branch whose migration carries an older timestamp than the create_table it depends on is the usual way the last one happens.

Fix: Correct the name, or fix the ordering by regenerating the migration so it gets a newer timestamp. On PostgreSQL, the transaction already undid the migration’s earlier statements, so rerun db:migrate after the fix.

PG::DuplicateTable: relation already exists

text
PG::DuplicateTable: ERROR:  relation "events" already exists
db/migrate/20260910140123_create_events_again.rb:3:in 'CreateEventsAgain#change'
 
Caused by:
ActiveRecord::StatementInvalid: PG::DuplicateTable: ERROR:  relation "events" already exists (ActiveRecord::StatementInvalid)

Cause: create_table for a table that is already there. The usual routes: two migrations create the same table (one of them from a merged branch), a migration file was copied and renamed, or the row for an applied migration was deleted from schema_migrations and Rails is trying to apply it again. The column-level sibling is PG::DuplicateColumn: ERROR: column "city" of relation "events" already exists, which is what you get when you rerun a migration that failed halfway with disable_ddl_transaction! in effect.

Fix: Delete the duplicate migration if the table is already tracked, or drop the half-applied table or column by hand and run db:migrate again. create_table :events, force: true makes the error go away by dropping the table first, which is the wrong fix anywhere near production data.

ActiveRecord::DuplicateMigrationVersionError and DuplicateMigrationNameError

text
ActiveRecord::DuplicateMigrationVersionError:  (ActiveRecord::DuplicateMigrationVersionError)
 
Multiple migrations have the version number 20260101000000.
text
ActiveRecord::DuplicateMigrationNameError:  (ActiveRecord::DuplicateMigrationNameError)
 
Multiple migrations have the name AddAToEvents.

Cause: Rails checks db/migrate before running anything. Two files sharing a timestamp raise the first error (hand-edited timestamps and merges are how that happens). Two files defining the same class name raise the second, even with different timestamps. A third check rejects file names that are not lowercase snake case:

text
ActiveRecord::IllegalMigrationNameError:  (ActiveRecord::IllegalMigrationNameError)
 
Illegal name for migration file: db/migrate/20260101000002_AddCToEvents.rb
	(only lower case letters, numbers, and '_' allowed).

Fix: Regenerate one of the two files with bin/rails generate migration so it gets a fresh timestamp, rename the duplicate class, or rename the file to 20260101000002_add_c_to_events.rb. Nothing has run at this point, so no database state needs repair.

PG::ActiveSqlTransaction: CREATE INDEX CONCURRENTLY cannot run inside a transaction block

text
PG::ActiveSqlTransaction: ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block
db/migrate/20260910140010_add_index_on_events_user_id.rb:3:in 'AddIndexOnEventsUserId#change'
 
Caused by:
ActiveRecord::StatementInvalid: PG::ActiveSqlTransaction: ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block (ActiveRecord::StatementInvalid)

Cause: add_index :events, :user_id, algorithm: :concurrently inside a migration that still runs in a transaction. PostgreSQL builds concurrent indexes outside transactions by design, so the DDL transaction Rails opened around the migration is exactly what the database rejects.

Fix: Switch the transaction off for this migration with the class-level macro:

Ruby
class AddIndexOnEventsUserId < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!
 
  def change
    add_index :events, :user_id, algorithm: :concurrently
  end
end

Rolling this migration back works too; Rails emits remove_index(:events, :user_id, {algorithm: :concurrently}). Because the migration no longer runs in a transaction, a failure partway through is not undone for you, so keep such migrations to the one statement that needs it. A concurrent build that fails leaves an INVALID index behind; drop it by hand before retrying.

ActiveRecord::ConcurrentMigrationError: Cannot run migrations because another migration process is currently running

text
ActiveRecord::ConcurrentMigrationError:  (ActiveRecord::ConcurrentMigrationError)
 
Cannot run migrations because another migration process is currently running.

Cause: A second db:migrate started while the first still held the advisory lock. This is reproduced with a migration that sleeps for 20 seconds and a second bin/rails db:migrate launched 8 seconds later. The second process exits with this error immediately, and the first finishes normally. On a deploy, the second process is a second web container booting at the same time, or a release task racing the container entrypoint.

Fix: Nothing to repair. Let the first process finish, then rerun db:migrate; it finds nothing pending if the first run applied everything.

PG::DatatypeMismatch: column cannot be cast automatically

text
PG::DatatypeMismatch: ERROR:  column "price" cannot be cast automatically to type integer
HINT:  You might need to specify "USING price::integer".
db/migrate/20260910135916_change_events_price_to_string.rb:10:in 'block in ChangeEventsPriceToString#down'

Cause: change_column (or t.change) to a type PostgreSQL cannot convert implicitly, here character varying to integer. The other direction, integer to string, works without help, which is why this usually shows up in a down method that nobody ran until the rollback.

Fix: Pass the cast: t.change :price, :integer, using: "price::integer", or change_column :events, :price, :integer, using: "price::integer". The cast runs against every existing row, so a value that is not a valid integer fails the statement; clean the data first.

Multiple Databases and Migrations

Rails 6 added first-class support for more than one database, and a Rails 8.1 app ships with it configured. The generated production block of config/database.yml already gives Solid Cache and Solid Queue their own databases, each with its own migrations directory:

YAML
production:
  primary: &primary_production
    <<: *default
    database: demo_production
    username: demo
    password: <%= ENV["DEMO_DATABASE_PASSWORD"] %>
  cache:
    <<: *primary_production
    database: demo_production_cache
    migrations_paths: db/cache_migrate
  queue:
    <<: *primary_production
    database: demo_production_queue
    migrations_paths: db/queue_migrate

Adding your own follows the same shape. An analytics database in development:

YAML
development:
  primary:
    <<: *default
    database: demo_development
  analytics:
    <<: *default
    database: demo_development_analytics
    migrations_paths: db/analytics_migrate

migrations_paths is required for every database other than primary: migrations for analytics live in db/analytics_migrate, so a db/migrate file never runs against the wrong database. The generator takes a --database option to put the file in the right place:

Shell
$ bin/rails generate migration CreateExperiments rule:string active:boolean --database analytics
      invoke  active_record
      create    db/analytics_migrate/20260910140136_create_experiments.rb

bin/rails db:create creates every database in the configuration, and each one gets its own namespaced tasks (db:migrate:analytics, db:migrate:status:analytics, db:rollback:analytics, db:migrate:down:analytics, and so on):

Shell
$ bin/rails db:create
Database 'demo_development' already exists
Database 'demo_test' already exists
Created database 'demo_development_analytics'
Created database 'demo_test_analytics'

bin/rails db:migrate:analytics migrates only that database, while a plain bin/rails db:migrate migrates every database with pending migrations in one run:

Shell
$ bin/rails db:migrate
== 20260910140144 AddLabelToEvents: migrating =================================
-- add_column(:events, :label, :string)
   -> 0.0047s
== 20260910140144 AddLabelToEvents: migrated (0.0047s) ========================

== 20260910140147 AddOwnerToExperiments: migrating ============================
-- add_column(:experiments, :owner, :string)
   -> 0.0039s
== 20260910140147 AddOwnerToExperiments: migrated (0.0040s) ===================

db:migrate itself takes no database option: bin/rails db:migrate --db=analytics fails with invalid option: --db=analytics (the generator does accept --db as an alias for --database). db:migrate:status prints one block per database. Each database has its own schema_migrations and ar_internal_metadata tables, and its own dump, db/analytics_schema.rb, next to db/schema.rb.

Rollbacks need the namespace. With more than one database, bin/rails db:rollback refuses to guess:

text
You're using a multiple database application. To use `db:rollback` you must run the namespaced task with a VERSION. Available tasks are db:rollback:primary and db:rollback:analytics.

On the model side, an abstract class tells Active Record where a model’s table lives:

Ruby
class AnalyticsRecord < ApplicationRecord
  self.abstract_class = true
 
  connects_to database: { writing: :analytics, reading: :analytics }
end
Ruby
class Experiment < AnalyticsRecord
end

Experiment.connection_db_config.name is analytics, and Event’s is primary. The multiple databases guide covers replicas, sharding, and automatic connection switching.

Running Migrations During Deployment

Migrations change the database, and the new code depends on those changes, so the schema has to be in place before the new code serves a request. The reverse constraint holds for removals: a column the running code still reads has to survive until that code is gone. That’s why removing a column is a two-deploy operation.

The command for the job is bin/rails db:prepare, which does the right thing for whatever state it finds. Against a database that does not exist yet, it creates the database, loads the schema files (db/schema.rb and, in a multi-database app, db/analytics_schema.rb and the others), and runs db/seeds.rb. Here’s what that looks like with a puts added to the seeds file to prove the point:

Shell
$ RAILS_ENV=test bin/rails db:prepare
Created database 'demo_test'
Created database 'demo_test_analytics'
SEEDS RAN (db/seeds.rb)

Against an existing database it runs pending migrations, and when nothing is pending it exits silently. In development it also prepares the test database (unless SKIP_TEST_DATABASE or DATABASE_URL is set), so bin/rails db:prepare with one pending migration prints two migrating blocks, one per environment:

Shell
$ bin/rails db:prepare
== 20260910140204 AddNotesToEvents: migrating =================================
-- add_column(:events, :notes, :text)
   -> 0.0025s
== 20260910140204 AddNotesToEvents: migrated (0.0026s) ========================

== 20260910140204 AddNotesToEvents: migrating =================================
-- add_column(:events, :notes, :text)
   -> 0.0046s
== 20260910140204 AddNotesToEvents: migrated (0.0046s) ========================

A Rails 8.1 app is generated with a Dockerfile whose entrypoint is bin/docker-entrypoint:

Shell
#!/bin/bash -e
 
# If running the rails server then create or migrate existing database
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
  ./bin/rails db:prepare
fi
 
exec "${@}"

The Dockerfile sets ENTRYPOINT ["/rails/bin/docker-entrypoint"] and CMD ["./bin/rails", "server"], so a web container migrates itself on boot. A job container started with bin/jobs skips the db:prepare and never touches the schema. The config/deploy.yml that Kamal 2.12.0 generates has no migration step of its own: the entrypoint is the migration step. When two web containers boot at once, the advisory lock serializes them. The loser exits with ActiveRecord::ConcurrentMigrationError (the entrypoint runs under bash -e, so that container stops instead of serving on a schema it did not verify).

On Heroku, the release phase of the Procfile runs before the new dynos start:

Shell
# Procfile
web: bin/puma -C config/puma.rb
release: bundle exec rake db:migrate

A migration that takes a long lock shows up in production as a step change rather than a bug: response times climb and throughput drops for every request that touches the locked table while the DDL waits, and the queries queued behind it appear in your slow-query list. AppSignal’s deploy markers draw that line on the graph, so a regression that starts the minute db:migrate ran points at the migration rather than the code around it.

Conclusion

Most migration trouble comes from one of three places: a change method Rails cannot invert, a transaction that was switched off, or two processes running db:migrate at once. Each has a recognizable error string, and each fix is small once you know which of the three you are looking at. Keep schema.rb committed, keep migrations pinned to the release they were written for, and let db:prepare do the work on deploy.

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

How do I fix ActiveRecord::PendingMigrationError in Rails?
Run bin/rails db:migrate to apply the migrations the error lists, then restart the server. In the test environment Rails loads db/schema.rb first and only raises when a migration file is newer than that schema, so migrate in development and commit the updated schema.rb.
What does ActiveRecord::IrreversibleMigration mean in Rails?
Rails could not work out how to undo a step in your change method: remove_column without a column type, change_column, drop_table without a block, or raw execute. Replace change with up and down methods, or wrap the step in reversible with explicit up and down blocks.
Does a failed Rails migration roll back automatically?
On PostgreSQL and SQLite, yes: each migration runs inside a transaction, so a failure undoes every statement in it and the migration stays marked down. MySQL cannot roll back DDL, and disable_ddl_transaction! switches the transaction off, so earlier statements stay applied and a rerun can hit a duplicate column error.
How do I roll back a Rails migration to a specific version?
Run bin/rails db:migrate VERSION=20260910135847 to migrate down to that version, bin/rails db:rollback STEP=2 to undo the last two, or bin/rails db:migrate:down VERSION=… for a single migration. In a multi-database app, use the namespaced task, such as db:rollback:primary.
How do migrations work with multiple databases in Rails 8?
Each database entry in database.yml gets its own migrations_paths directory and its own schema_migrations table. Generate with the --database flag, run bin/rails db:migrate to migrate every database or db:migrate:analytics for one of them, and point models at it with connects_to on an abstract class.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Prathamesh Sonpatki

Prathamesh Sonpatki

Guest author Prathamesh Sonpatki is a developer working in Ruby and Ruby on Rails. He also co-organizes RubyConfIndia and DeccanRubyConf.

All articles by Prathamesh Sonpatki

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

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

Discover AppSignal