Agent Skills: Rails Migrations Expert

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.

UncategorizedID: thinkoodle/rails-skills/migrations

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ThinkOodle/rails-skills/tree/HEAD/skills/migrations

Skill Files

Browse the full folder contents for migrations.

Download Skill

Loading file tree…

skills/migrations/SKILL.md

Skill Metadata

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.

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 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

# 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_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:

# 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.