migrations
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.
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4routing
Expert guidance for defining routes in Rails applications. Use when adding routes, working with resources, nested routes, namespaces, path helpers, routes.rb, RESTful design, API routes, URL helpers, or any routing-related task. Covers resources, singular resources, nesting, namespace vs scope, constraints, concerns, member/collection routes, and route testing.
4