Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 011 / 100

Migrations in Rails: a Ruby on Rails guide for developers

Version-controlled schema changes.

Expona Admin7 min read

Migrations in Rails: a Ruby on Rails guide for developers

Database schema changes are a constant part of application development, yet getting them wrong can cascade into production incidents that take hours to untangle. Migrations in Rails provide a version-controlled, repeatable way to evolve your database structure safely, but understanding their nuances separates teams that ship confidently from those that sweat during deployments. In this guide, we'll walk through how to leverage Rails migrations effectively, from generation through production safeguards.

Understanding migrations and why they matter

A migration is a versioned Ruby file that describes a set of database changes. Rails timestamps each migration file, creating a linear history of schema evolution. When you run rails db:migrate, Rails tracks which migrations have executed via a schema_migrations table, allowing team members and production servers to reach the same schema state deterministically.

This approach solves a critical problem: without migrations, you'd either commit SQL files manually or rely on undocumented schema changes. Migrations make schema changes auditable, reversible, and safe to collaborate on.

bin/rails generate migration: creating and customizing migrations

The command-line generator is your primary tool for creating migrations. Rails provides several patterns to match your intent.

Basic migration generation

The simplest approach generates an empty migration:

ruby
bin/rails generate migration AddEmailToUsers

This creates a timestamped file like db/migrate/20240111143022_add_email_to_users.rb with empty up and down methods. You then fill in the schema changes manually.

Smart generators for common operations

Rails recognizes migration names that follow conventions and generates the appropriate methods. These patterns save time and reduce errors:

ruby
bin/rails generate migration AddEmailToUsers email:string

This generates:

ruby
class AddEmailToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :email, :string
  end
end

Rails understands patterns like Add{Columns}To{Table}, Remove{Columns}From{Table}, Create{Table}, and others. For example:

ruby
bin/rails generate migration CreateOrders user:references status:string amount:decimal created_at:datetime

Generates a full create_table block with appropriate column types and a foreign key reference. This cuts down boilerplate and catches type mismatches early.

When to write migrations manually

For complex schema changes—renaming columns safely, backfilling data, creating custom functions, or conditional logic—you'll write migrations by hand. The generator provides a scaffold, but your domain knowledge drives the implementation.

Reversible change methods: up and down

Migrations support two different structures depending on your needs.

The change method (preferred)

The change method handles both migration and rollback automatically. Rails infers the reverse operation for most common actions:

ruby
class AddStatusToOrders < ActiveRecord::Migration[7.0]
  def change
    add_column :orders, :status, :string, default: 'pending'
    add_index :orders, :status
  end
end

When you run rails db:rollback, Rails reverses this: it removes the index, then drops the column. This is clean and concise for straightforward changes.

The up and down methods (when needed)

Not all operations are inherently reversible. If you're backfilling data, running raw SQL, or deleting records, you need explicit up and down methods:

ruby
class BackfillProductPrices < ActiveRecord::Migration[7.0]
  def up
    Product.find_each do |product|
      product.update(sale_price: product.price * 0.9)
    end
  end

  def down
    Product.update_all(sale_price: nil)
  end
end

Here, up calculates and backfills sale_price for all products. down clears those values. This explicit structure makes your intent clear and ensures rollback won't corrupt data.

Executing raw SQL when needed

Sometimes you need SQL operations that ActiveRecord migrations don't abstract. Use execute:

ruby
class CreateOrdersIndex < ActiveRecord::Migration[7.0]
  def up
    execute <<-SQL
      CREATE INDEX idx_orders_user_created 
      ON orders(user_id, created_at DESC) 
      WHERE status = 'completed'
    SQL
  end

  def down
    execute 'DROP INDEX IF EXISTS idx_orders_user_created'
  end
end

Use raw SQL judiciously—it's database-specific and harder to test—but it's essential for advanced indexing strategies or custom data operations.

Add indexes for foreign keys: a performance imperative

Foreign keys define relationships between tables, but they don't automatically create indexes. Without indexes on foreign key columns, joins and lookups become full table scans in production.

Foreign keys and their performance cost

When you generate a migration with a reference:

ruby
bin/rails generate migration CreateOrders user:references

Rails creates:

ruby
class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.references :user, null: false, foreign_key: true
      t.timestamps
    end
  end
end

The references method adds a user_id column, a foreign key constraint, and an index by default (in Rails 6.1+). However, always verify your Rails version and explicitly add indexes for safety:

ruby
class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.references :user, null: false, foreign_key: true, index: true
      t.references :product, null: false, foreign_key: true, index: true
      t.decimal :amount, precision: 10, scale: 2
      t.timestamps
    end
  end
end

Adding indexes to existing foreign keys

If you inherit a codebase where foreign key columns lack indexes, create a dedicated migration:

ruby
class AddIndexesToForeignKeys < ActiveRecord::Migration[7.0]
  def change
    add_index :orders, :user_id unless index_exists?(:orders, :user_id)
    add_index :orders, :product_id unless index_exists?(:orders, :product_id)
    add_index :order_items, :order_id unless index_exists?(:order_items, :order_id)
  end
end

The index_exists? check prevents errors if the index already exists. This is especially useful when working across multiple team members' migrations.

Composite indexes for query patterns

If your queries frequently filter by foreign key and another column, create a composite index:

ruby
class AddCompositeIndexToOrders < ActiveRecord::Migration[7.0]
  def change
    add_index :orders, [:user_id, :status], name: 'idx_orders_user_status'
  end
end

This single index accelerates both Order.where(user_id: x) and Order.where(user_id: x, status: 'pending'), reducing query time in production.

Never edit migrations that ran in production

This is the golden rule of migrations. Once a migration has executed in production, treat it as immutable. Editing it breaks the migration history contract and can cause catastrophic schema mismatches across your infrastructure.

Why editing production migrations is catastrophic

Imagine you deploy a migration to production, then realize it has a typo. You edit the file and re-run it locally. Your development database now has the corrected schema. But production still has the original, broken schema. When a new team member checks out the code, they run migrations from scratch and get the corrected version. Now development and production diverge.

Worse, other developers may have already created migrations that depend on the buggy version. Rolling back becomes a tangled mess.

The correct approach: create a follow-up migration

If you spot an error in a migration that's already in production, create a new migration to fix it:

ruby
// Original, buggy migration (immutable)
class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :name
      t.string :emai  // typo
      t.timestamps
    end
  end
end

// Follow-up migration (fixes the typo)
class FixEmailColumnInUsers < ActiveRecord::Migration[7.0]
  def change
    rename_column :users, :emai, :email
  end
end

This preserves history, keeps development and production aligned, and makes debugging easier months later when you wonder why that column has an odd name.

Catching errors before production

Use rails db:migrate:status to review pending migrations before deployment. Test migrations in a staging environment that mimics production data. Many teams run migrations in a separate CI stage before the actual application deploy, catching syntax errors and schema conflicts early.

Common mistakes

Forgetting indexes on foreign keys

The most frequent oversight is creating a foreign key without an index. Your tests pass with minimal data, but production queries slow to a crawl. Always explicitly add indexes or verify Rails generated them.

Including model logic in migrations

Don't call your Rails models inside migrations if you can avoid it. Models change over time; a migration that calls User.update_all today may break months later when the User model changes. Use raw SQL or direct ActiveRecord calls that don't depend on model behavior:

ruby
// Bad: depends on User model logic
def up
  User.all.each { |u| u.update(age: calculate_age(u.dob)) }
end

// Better: raw SQL or direct updates
def up
  execute <<-SQL
    UPDATE users 
    SET age = EXTRACT(YEAR FROM AGE(dob))
  SQL
end

Unhandled edge cases in data migrations

When backfilling data, account for nulls, duplicates, and existing values. A migration that assumes every user has an email will fail silently if some users don't:

ruby
def up
  User.find_each do |user|
    user.update(slug: user.name.downcase.gsub(' ', '-')) if user.name.present?
  end
end

Creating large indexes without careful timing

On large tables, index creation can lock the table and cause downtime. Use algorithm: :concurrently on PostgreSQL (requires no transaction) or schedule index creation during low-traffic windows.

Key takeaways

  • Use bin/rails generate migration with smart naming conventions to generate boilerplate and reduce errors.
  • Prefer the change method for reversible operations; use up and down for complex or data-dependent changes.
  • Always add indexes to foreign keys, either via foreign_key: true, index: true or explicit add_index calls.
  • Never edit a migration after it's run in production. Create a follow-up migration instead.
  • Test migrations in staging before production. Use rails db:migrate:status to review pending changes.
  • Keep migrations focused. Each migration should handle one logical change, making rollbacks and debugging straightforward.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles