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:
bin/rails generate migration AddEmailToUsersThis 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:
bin/rails generate migration AddEmailToUsers email:stringThis generates:
class AddEmailToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :email, :string
end
endRails understands patterns like Add{Columns}To{Table}, Remove{Columns}From{Table}, Create{Table}, and others. For example:
bin/rails generate migration CreateOrders user:references status:string amount:decimal created_at:datetimeGenerates 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:
class AddStatusToOrders < ActiveRecord::Migration[7.0]
def change
add_column :orders, :status, :string, default: 'pending'
add_index :orders, :status
end
endWhen 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:
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
endHere, 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:
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
endUse 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:
bin/rails generate migration CreateOrders user:referencesRails creates:
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
endThe 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:
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
endAdding indexes to existing foreign keys
If you inherit a codebase where foreign key columns lack indexes, create a dedicated migration:
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
endThe 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:
class AddCompositeIndexToOrders < ActiveRecord::Migration[7.0]
def change
add_index :orders, [:user_id, :status], name: 'idx_orders_user_status'
end
endThis 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:
// 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
endThis 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:
// 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
endUnhandled 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:
def up
User.find_each do |user|
user.update(slug: user.name.downcase.gsub(' ', '-')) if user.name.present?
end
endCreating 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 migrationwith smart naming conventions to generate boilerplate and reduce errors. - Prefer the
changemethod for reversible operations; useupanddownfor complex or data-dependent changes. - Always add indexes to foreign keys, either via
foreign_key: true, index: trueor explicitadd_indexcalls. - 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:statusto review pending changes. - Keep migrations focused. Each migration should handle one logical change, making rollbacks and debugging straightforward.






Comments
Loading comments…