# Migrations

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

- Skill: `thinkoodle/migrations` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add thinkoodle/migrations`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thinkoodle/migrations/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: thinkoodle (https://skillmd.com/u/thinkoodle)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thinkoodle/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.

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

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

```ruby
# 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:

```bash
# 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:

```bash
# 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):

```ruby
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`:

```ruby
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`:

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

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

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

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

```ruby
# 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

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

```ruby
# 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`:

```ruby
# 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`:

```ruby
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:

```ruby
# 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:

```bash
# 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

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

