Introduction
Beyond basic table creation, migrations handle complex schema changes, data transformations, and database-specific features. Mastering advanced migration techniques is essential for maintaining production databases without downtime or data loss.
Key Concepts
reversibleblock: Defines separateupanddownlogic within achangemethod for operations Rails cannot automatically reverse.execute: Runs raw SQL within a migration for database-specific features.- CHECK constraint: A database-level rule that enforces a boolean expression on column values.
- Partial index: An index that only includes rows matching a condition, saving space and improving query speed.
disable_ddl_transaction!: Opts out of wrapping the migration in a transaction, required for concurrent index creation.
Real World Context
Production applications evolve constantly. You will rename columns, change types, add constraints, create database functions, and backfill data. Each operation has pitfalls that can lock tables, lose data, or break running code. Knowing the right migration technique for each scenario prevents production incidents.
Deep Dive
Reversible Migrations
Rails automatically reverses most operations (like add_column). For complex cases, use reversible:
rubyclass AddFullNameToUsers < ActiveRecord::Migration[8.1] def change add_column :users, :full_name, :string reversible do |dir| dir.up do User.find_each do |user| user.update_column(:full_name, "#{user.first_name} #{user.last_name}") end end dir.down do # No action needed -- column will be dropped end end end end
The reversible block lets you define forward and backward behavior while keeping the main change method.
Separate up and down Methods
For migrations that cannot use change at all:
rubyclass ChangeStatusToInteger < ActiveRecord::Migration[8.1] def up add_column :orders, :status_code, :integer, default: 0 execute <<-SQL UPDATE orders SET status_code = CASE status WHEN 'pending' THEN 0 WHEN 'processing' THEN 1 WHEN 'shipped' THEN 2 ELSE 0 END SQL remove_column :orders, :status rename_column :orders, :status_code, :status end def down add_column :orders, :status_text, :string execute <<-SQL UPDATE orders SET status_text = CASE status WHEN 0 THEN 'pending' WHEN 1 THEN 'processing' WHEN 2 THEN 'shipped' ELSE 'pending' END SQL remove_column :orders, :status rename_column :orders, :status_text, :status end end
Index Options
rubyclass AddIndexes < ActiveRecord::Migration[8.1] def change # Partial index add_index :articles, :published_at, where: "published = true" # Expression index (PostgreSQL) add_index :users, "LOWER(email)", name: "index_users_on_lower_email" end end # Concurrent index (no table lock, PostgreSQL) class AddConcurrentIndex < ActiveRecord::Migration[8.1] disable_ddl_transaction! def change add_index :large_table, :column, algorithm: :concurrently end end
Concurrent indexes require disable_ddl_transaction! because they cannot run inside a transaction.
Check Constraints
rubyclass AddConstraints < ActiveRecord::Migration[8.1] def change add_check_constraint :products, "price > 0", name: "products_price_positive" add_check_constraint :orders, "status IN ('pending', 'processing', 'shipped', 'delivered')", name: "orders_valid_status" end end
Common Pitfalls
- Forgetting
disable_ddl_transaction!-- Concurrent index creation fails inside a transaction. Always add this directive foralgorithm: :concurrently. - Large data updates in transactions -- Updating millions of rows in a single transaction can lock the table and exhaust memory. Use
in_batcheswithdisable_ddl_transaction!. - Non-reversible migrations without
down-- If you useexecuteorremove_columninchange, Rails may not know how to reverse it. Always definedownor usereversible.
Best Practices
- Name all constraints -- Use the
name:option so constraints are easy to identify in error messages and rollbacks. - Use
in_batchesfor data migrations -- Process large tables in chunks to avoid locking and memory issues. - Separate schema and data migrations -- Keep DDL changes (column additions) and DML changes (data backfills) in separate migrations for clarity.
Summary
- Use
reversibleor separateup/downmethods for operations Rails cannot auto-reverse. executeruns raw SQL for database-specific features like functions and triggers.- Partial indexes and expression indexes optimize queries on subsets of data.
disable_ddl_transaction!is required for concurrent index creation.- Always name constraints and separate schema changes from data backfills.
Code Examples
class AddFullNameWithBackfill < ActiveRecord::Migration[8.1]
def change
add_column :users, :full_name, :string
reversible do |dir|
dir.up do
User.in_batches.update_all("full_name = first_name || ' ' || last_name")
end
end
end
end