| name | migrations |
| description | Expert guidance for writing safe, reversible Active Record migrations in Rails applications. Use when creating a migration, adding a column, removing a column, changing schema, modifying a table, creating a table, adding an index, adding a foreign key, renaming a column, changing column type, database migration, schema change, rolling back, migration error, data migration, multi-database migration, or any database structure change. |
| allowed-tools | Read, Grep, Glob, Write, Edit, Bash(bin/rails generate migration*), Bash(bin/rails db:*), Bash(bundle exec rails db:*), Bash(bin/rails generate model*) |
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.
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email
t.string :name
end
end
end
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.
sqlite3 db/development.sqlite3 "ALTER TABLE users ADD COLUMN name TEXT"
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.
class ChangePostsBodyToText < ActiveRecord::Migration[8.1]
def up
change_column :posts, :body, :text
end
def down
change_column :posts, :body, :string
end
end
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:
cat db/schema.rb | grep -A 20 "create_table \"users\""
bin/rails db:migrate:status
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:
bin/rails generate migration CreatePosts title:string body:text published_at:datetime
bin/rails generate migration AddCategoryToPosts category:string
bin/rails generate migration RemoveLegacyFieldFromUsers legacy_field:string
bin/rails generate migration AddUserRefToPosts user:references
bin/rails generate migration AddPartNumberToProducts part_number:string:index
bin/rails generate migration ChangePostsBodyToText
Generator shortcuts:
name:string — default type is string if omitted, so name alone works
user:references — creates user_id column + index + foreign key
price:decimal{5,2} — precision and scale modifiers
email:string! — adds null: false constraint
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
bin/rails db:migrate
bin/rails db:migrate:status
bin/rails db:rollback
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.
t.bigint :author_id, null: false
t.references :author, null: false, foreign_key: true
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.
add_column :posts, :status, :string, null: false
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.
class BackfillPostSlugs < ActiveRecord::Migration[8.1]
def up
Post.find_each { |p| p.update!(slug: p.title.parameterize) }
end
end
class BackfillPostSlugs < ActiveRecord::Migration[8.1]
def up
execute <<~SQL
UPDATE posts SET slug = lower(replace(title, ' ', '-')) WHERE slug IS NULL
SQL
end
end
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
add_column :posts, :migrated_at, :datetime, default: Time.current
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.
remove_column :users, :legacy_field
remove_column :users, :legacy_field, :string
change_column Is NOT Reversible
Rails can't guess the old type. Always use up/down:
def change
change_column :posts, :body, :text
end
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_reference
add_foreign_key / remove_foreign_key
add_timestamps / remove_timestamps
rename_column, rename_table, rename_index
change_column_default (needs from: and to:)
change_column_null
add_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:
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:
bin/rails db:migrate
bin/rails db:migrate:animals
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
bin/rails generate migration AddNameToUsers name:string
bin/rails generate model Post title:string body:text
bin/rails db:migrate
bin/rails db:migrate VERSION=20240428...
bin/rails db:rollback
bin/rails db:rollback STEP=3
bin/rails db:migrate:redo
bin/rails db:migrate:status
bin/rails db:schema:dump
bin/rails db:schema:load
bin/rails db:setup
bin/rails db:prepare
bin/rails db:reset
bin/rails db:migrate:reset
bin/rails db:migrate:up VERSION=20240428...
bin/rails db:migrate:down VERSION=20240428...
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.