thinkoodle/rails-skills

migrations

Expert guidance for writing safe, reversible Active Record migrations in Rails applications.

First seen May 1, 2026

Installation

$ npx skills add thinkoodle/rails-skills --skill migrations

Summary

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

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from thinkoodle/rails-skills · top by installs.

npx skills add thinkoodle/rails-skills

Browse all from thinkoodle/rails-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 5
License MIT
Default branch master
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Allowed toolsRead, Grep, Glob, Write, Edit, Bash(bin/rails generate migration*), Bash(bin/rails db:*), Bash(bundle exec rails db:*), Bash(bin/rails generate model*)

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 11,944 B
  • docs SUMMARY.md 453 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 14 installs

SKILL.md

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:

  • createtable / droptable (drop needs block + options)
  • addcolumn / removecolumn (remove needs type)
  • addindex / removeindex (remove needs column + options)
  • addreference / removereference
  • addforeignkey / removeforeignkey
  • addtimestamps / removetimestamps
  • renamecolumn, renametable, rename_index
  • changecolumndefault (needs from: and to:)
  • changecolumnnull
  • addcheckconstraint / removecheckconstraint

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 migrationspaths directory (e.g., db/animalsmigrate/).

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.