Create safe, reversible database migrations with zero-downtime strategies, proper indexing, and strong_migrations patterns
change for simple ops, explicit up/down for complex ones, and raise ActiveRecord::IrreversibleMigration only as a last resort.db:migrate then db:rollback then db:migrate before pushing.bin/rails generate migration AddStatusToEvents status:integer
bin/rails db:migrate
bin/rails db:rollback
bin/rails db:migrate:status
- [ ] Migration is reversible (has down or uses change)
- [ ] Large tables use batching for data updates
- [ ] Indexes added concurrently on large tables
- [ ] Foreign keys have indexes
- [ ] NOT NULL added in two steps (for existing columns with data)
- [ ] Default values don't lock table (Rails 8 handles this safely)
- [ ] Tested rollback locally
- [ ] strong_migrations checks pass
class AddStatusToEvents < ActiveRecord::Migration[8.0]
def change
add_column :events, :status, :integer, default: 0, null: false
end
end
For tables with existing data, split into two migrations to avoid locking:
# Step 1: Add column with default, allow NULL temporarily
class AddPriorityToTasks < ActiveRecord::Migration[8.0]
def change
add_column :tasks, :priority, :integer, default: 0
end
end
# Step 2: After backfill, add NOT NULL constraint
class AddNotNullToTasksPriority < ActiveRecord::Migration[8.0]
def change
change_column_null :tasks, :priority, false
end
end
class AddIndexToEventsStatus < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :events, :status, algorithm: :concurrently, if_not_exists: true
end
end
class AddAccountToEvents < ActiveRecord::Migration[8.0]
def change
add_reference :events, :account, null: false, foreign_key: true, index: true
end
end
First remove all code references, then migrate:
class RemoveLegacyFieldFromEvents < ActiveRecord::Migration[8.0]
def change
safety_assured { remove_column :events, :legacy_field, :string }
end
end
class AddStatusEnumToOrders < ActiveRecord::Migration[8.0]
def change
add_column :orders, :status, :integer, default: 0, null: false
add_index :orders, :status
end
end
Model side:
class Order < ApplicationRecord
enum :status, { pending: 0, confirmed: 1, shipped: 2, delivered: 3, cancelled: 4 }
end
class RenameNameToTitleOnEvents < ActiveRecord::Migration[8.0]
def change
rename_column :events, :name, :title
end
end
# ❌ DANGEROUS — can lose data or lock table
change_column :events, :budget, :decimal
# ✅ SAFE — three-step approach:
# Step 1: Add new column
add_column :events, :budget_decimal, :decimal, precision: 10, scale: 2
# Step 2: Backfill (separate migration or rake task)
Event.in_batches.update_all("budget_decimal = budget")
# Step 3: Remove old column and rename (after code updated)
safety_assured { remove_column :events, :budget, :integer }
rename_column :events, :budget_decimal, :budget
# ❌ Locks entire table
add_index :large_table, :column
# ✅ Non-blocking
disable_ddl_transaction!
add_index :large_table, :column, algorithm: :concurrently
Rule: Never put data changes in schema migrations. Create a separate migration file.
class BackfillEventStatus < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def up
Event.unscoped.in_batches(of: 1000) do |batch|
batch.where(status: nil).update_all(status: 0)
sleep(0.1) # Reduce database load
end
end
def down
# No rollback for data migration
end
end
# Migration adds the column only
class AddProcessedAtToEvents < ActiveRecord::Migration[8.0]
def change
add_column :events, :processed_at, :datetime
end
end
# Separate job handles backfill
class BackfillProcessedAtJob < ApplicationJob
def perform(start_id, end_id)
Event.where(id: start_id..end_id, processed_at: nil)
.update_all(processed_at: Time.current)
end
end
# For queries: WHERE account_id = ? AND status = ?
add_index :events, [:account_id, :status]
# Column order matters! This index helps:
# WHERE account_id = ?
# WHERE account_id = ? AND status = ?
# But NOT:
# WHERE status = ? (needs its own index)
# Index only active records — smaller, faster
add_index :events, :event_date,
where: "status = 0", name: "index_events_on_date_active"
# Index only non-null values
add_index :users, :reset_token,
where: "reset_token IS NOT NULL"
add_index :users, :email, unique: true
add_index :event_vendors, [:event_id, :vendor_id], unique: true
# With automatic index
add_reference :events, :venue, foreign_key: true
# To existing column
add_foreign_key :events, :accounts
# With custom column name
add_foreign_key :events, :users, column: :organizer_id
# ON DELETE options
add_foreign_key :comments, :posts, on_delete: :cascade # Delete children
add_foreign_key :posts, :users, column: :author_id, on_delete: :nullify # Set NULL
add_foreign_key :orders, :users, on_delete: :restrict # Prevent deletion
# Gemfile
gem "strong_migrations"
# config/initializers/strong_migrations.rb
StrongMigrations.start_after = 20240101000000
StrongMigrations.target_version = 16 # PostgreSQL version
# Custom checks
StrongMigrations.add_check do |method, args|
if method == :add_column && args[1] == :events
stop! "Check with team before modifying events table"
end
end
class AddColumnWithDefault < ActiveRecord::Migration[8.0]
def change
safety_assured do
add_column :events, :priority, :integer, default: 0, null: false
end
end
end
change (automatic reversal)class CreateEvents < ActiveRecord::Migration[8.0]
def change
create_table :events do |t|
t.string :name, null: false
t.date :event_date
t.references :account, null: false, foreign_key: true
t.timestamps
end
add_index :events, [:account_id, :event_date]
end
end
up/down (manual reversal)class AddBudgetConstraint < ActiveRecord::Migration[8.0]
def up
execute <<-SQL
ALTER TABLE events ADD CONSTRAINT check_positive_budget
CHECK (budget_cents >= 0)
SQL
end
def down
execute <<-SQL
ALTER TABLE events DROP CONSTRAINT check_positive_budget
SQL
end
end
class DropLegacyTable < ActiveRecord::Migration[8.0]
def up
drop_table :legacy_events
end
def down
raise ActiveRecord::IrreversibleMigration, "Cannot restore dropped table"
end
end
change_column to add NOT NULL on a populated table — it locks the entire table. Use the two-step pattern: add column with default, then add constraint.disable_ddl_transaction! with concurrent indexes — algorithm: :concurrently cannot run inside a transaction; the migration will fail without this.[:account_id, :status] helps queries on account_id alone but NOT status alone. Put the most selective/filtered column first.add_reference auto-creates an index — don't add a duplicate index manually when using add_reference or t.references.db:rollback succeeds)algorithm: :concurrently with disable_ddl_transaction!strong_migrations checks passdb:migrate && db:rollback && db:migrate cycle passes locally