Rails Migrations Expert
Write safe, reversible, forward-only migrations for Rails 8.1 applications.
Golden Rules
These are non-negotiable. Violating any of them causes real production pain.
1. NEVER Edit Old Migrations
Once a migration has been committed (especially if run in production), it is frozen forever. Don't touch it. Write a new migration instead.
# BAD — editing an existing, committed migration
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email
t.string :name # ← sneaking this in after the fact
end
end
end
# GOOD — new migration to add the column
class AddNameToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :name, :string
end
end
Why: Other developers and environments have already run the old version. Editing it breaks db:migrate for everyone and causes schema drift.
Exception: A migration you just generated that hasn't been committed or run anywhere else — edit freely.
2. NEVER Modify the Database Directly
All schema changes go through migrations. No exceptions.
# BAD
sqlite3 db/development.sqlite3 "ALTER TABLE users ADD COLUMN name TEXT"
# GOOD
bin/rails generate migration AddNameToUsers name:string
bin/rails db:migrate
Why: Direct changes aren't tracked, schema.rb falls out of sync, and other environments won't have the changes.
3. Always Move Forward
Migrations are append-only. To fix mistakes, write a new migration.
# Wrong column type? New migration.
class ChangePostsBodyToText < ActiveRecord::Migration[8.1]
def up
change_column :posts, :body, :text
end
def down
change_column :posts, :body, :string
end
end
# Need to remove a column? New migration.
class RemoveObsoleteColumnFromUsers < ActiveRecord::Migration[8.1]
def change
remove_column :users, :obsolete_field, :string
end
end
Instructions
Step 1: Understand What You're Changing
Before writing a migration, check the current schema:
# Check current schema
cat db/schema.rb | grep -A 20 "create_table \"users\""
# Check migration status
bin/rails db:migrate:status
# Check existing migrations
ls -la db/migrate/
Know the current state before changing it.
Step 2: Generate the Migration
Use Rails generators — they create the file with the right timestamp and class structure. Name your migrations descriptively using Rails conventions:
# Creating a table (CreateXxx)
bin/rails generate migration CreatePosts title:string body:text published_at:datetime
# Adding columns (AddXxxToYyy)
bin/rails generate migration AddCategoryToPosts category:string
# Removing columns (RemoveXxxFromYyy)
bin/rails generate migration RemoveLegacyFieldFromUsers legacy_field:string
# Adding a reference/foreign key
bin/rails generate migration AddUserRefToPosts user:references
# Adding an index
bin/rails generate migration AddPartNumberToProducts part_number:string:index
# General changes (descriptive name)
bin/rails generate migration ChangePostsBodyToText
Generator shortcuts:
name:string— default type isstringif omitted, sonamealone worksuser:references— createsuser_idcolumn + index + foreign keyprice:decimal{5,2}— precision and scale modifiersemail:string!— addsnull: falseconstraint
Step 3: Write the Migration Body
Use the change method for reversible operations (Rails handles rollback automatically):
class CreatePosts < ActiveRecord::Migration[8.1]
def change
create_table :posts do |t|
t.string :title, null: false
t.text :body
t.references :author, null: false, foreign_key: true
t.datetime :published_at
t.timestamps
end
add_index :posts, :published_at
add_index :posts, :title
end
end
For irreversible operations, use up/down:
class ChangePostsExcerptToText < ActiveRecord::Migration[8.1]
def up
change_column :posts, :excerpt, :text
end
def down
change_column :posts, :excerpt, :string, limit: 500
end
end
For mixed operations (some reversible, some not), use reversible inside change:
class AddStatusToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :status, :string, default: "draft"
reversible do |dir|
dir.up do
execute <<~SQL
UPDATE posts SET status = 'published' WHERE published_at IS NOT NULL
SQL
end
end
end
end
Step 4: Run and Verify
# Run pending migrations
bin/rails db:migrate
# Check it worked
bin/rails db:migrate:status
# If something's wrong with the LAST migration (before committing)
bin/rails db:rollback
# Fix the migration, then re-run
bin/rails db:migrate
Step 5: Commit schema.rb
Always commit db/schema.rb (or db/structure.sql) alongside your migration. This file is the authoritative schema snapshot.
Critical Gotchas
Always Index Foreign Keys
Every foreign key column needs an index. No exceptions.
# BAD — foreign key without index
t.bigint :author_id, null: false
# GOOD — use references (index included automatically)
t.references :author, null: false, foreign_key: true
# GOOD — manual column with explicit index
t.bigint :author_id, null: false, index: true
NOT NULL on Existing Tables Requires a Default
Adding a NOT NULL column to a table that already has rows will fail without a default value.
# BAD — fails if table has data
add_column :posts, :status, :string, null: false
# GOOD — provide a default
add_column :posts, :status, :string, null: false, default: "draft"
Never Reference Application Models in Migrations
Models change over time. Migrations are frozen in time. They will break.
# BAD — model may not exist or may have different validations later
class BackfillPostSlugs < ActiveRecord::Migration[8.1]
def up
Post.find_each { |p| p.update!(slug: p.title.parameterize) }
end
end
# GOOD — use raw SQL
class BackfillPostSlugs < ActiveRecord::Migration[8.1]
def up
execute <<~SQL
UPDATE posts SET slug = lower(replace(title, ' ', '-')) WHERE slug IS NULL
SQL
end
end
# ACCEPTABLE — inline class that won't change
class BackfillPostSlugs < ActiveRecord::Migration[8.1]
class Post < ApplicationRecord; end
def up
Post.where(slug: nil).find_each do |post|
post.update_column(:slug, post.title.parameterize)
end
end
end
Don't Use Dynamic Ruby Values as Defaults
# BAD — evaluates once at migration time, frozen forever
add_column :posts, :migrated_at, :datetime, default: Time.current
# GOOD — SQL function evaluates at row insertion time
add_column :posts, :migrated_at, :datetime, default: -> { "CURRENT_TIMESTAMP" }
Always Include Column Type in remove_column
Without the type, Rails can't reverse the migration.
# BAD — irreversible
remove_column :users, :legacy_field
# GOOD — reversible
remove_column :users, :legacy_field, :string
change_column Is NOT Reversible
Rails can't guess the old type. Always use up/down:
# BAD — will fail on rollback
def change
change_column :posts, :body, :text
end
# GOOD
def up
change_column :posts, :body, :text
end
def down
change_column :posts, :body, :string
end
Reversible Operations Quick Reference
Auto-reversible in change:
create_table/drop_table(drop needs block + options)add_column/remove_column(remove needs type)add_index/remove_index(remove needs column + options)add_reference/remove_referenceadd_foreign_key/remove_foreign_keyadd_timestamps/remove_timestampsrename_column,rename_table,rename_indexchange_column_default(needsfrom:andto:)change_column_nulladd_check_constraint/remove_check_constraint
Require up/down or reversible:
change_column(type changes)execute(raw SQL)- Any data manipulation
Data Migrations
Small, Schema-Related Updates
Include them in the migration with reversible:
class AddStatusToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :status, :integer, default: 0, null: false
reversible do |dir|
dir.up do
execute "UPDATE posts SET status = 1 WHERE published_at IS NOT NULL"
end
end
end
end
Large Data Migrations
Don't put them in schema migrations. Use a rake task or the maintenance_tasks gem:
# lib/tasks/data_migrations.rake
namespace :data do
desc "Backfill post statuses"
task backfill_post_statuses: :environment do
Post.where(status: nil).find_each(batch_size: 1000) do |post|
post.update_column(:status, post.published_at? ? 1 : 0)
end
end
end
Why separate? Data migrations can be slow, may need to run at specific times, and have different rollback characteristics than schema changes.
Multi-Database Migrations
When the app uses multiple databases:
# Default database migrations go in db/migrate/
# Other databases use their own directories (configured in database.yml)
# Run specific database migrations
bin/rails db:migrate # primary database
bin/rails db:migrate:animals # "animals" database (example)
# Generate for a specific database
bin/rails generate migration CreateDogs name:string --database animals
Migration files for secondary databases live in their configured migrations_paths directory (e.g., db/animals_migrate/).
Commands Reference
# Generate
bin/rails generate migration AddNameToUsers name:string
bin/rails generate model Post title:string body:text
# Run
bin/rails db:migrate # Run all pending
bin/rails db:migrate VERSION=20240428... # Migrate to specific version
# Rollback
bin/rails db:rollback # Undo last migration
bin/rails db:rollback STEP=3 # Undo last 3
bin/rails db:migrate:redo # Rollback + re-migrate last
# Status
bin/rails db:migrate:status # Show up/down status of all
# Schema
bin/rails db:schema:dump # Regenerate schema.rb
bin/rails db:schema:load # Load schema (destructive!)
# Setup / Reset
bin/rails db:setup # Create + load schema + seed
bin/rails db:prepare # Idempotent setup
bin/rails db:reset # Drop + setup
bin/rails db:migrate:reset # Drop + migrate from scratch
# Specific migration
bin/rails db:migrate:up VERSION=20240428...
bin/rails db:migrate:down VERSION=20240428...
# Environment
bin/rails db:migrate RAILS_ENV=test
When to Use This Skill
- Creating new tables or modifying existing ones
- Adding, removing, or renaming columns
- Adding indexes or foreign keys
- Changing column types or constraints
- Writing data backfill migrations
- Debugging migration errors or rollback issues
- Setting up multi-database migrations
- Understanding schema.rb vs structure.sql
For detailed column types, index patterns, UUID setup, check constraints, and advanced examples, see reference.md in this skill directory.