Introduction
Rails migrations are powerful tools for managing database schema changes. However, there are scenarios where we need more control over how these migrations execute.
We might need to log every schema change for audit purposes. We might want to prevent dangerous operations in production environments. Or we might need to route migrations through an external service for distributed systems.
Rails 7.1 introduced Execution Strategies to address these needs. This feature provides a way to customize the entire migration execution layer. Rails 8.1 further enhanced this by allowing per-adapter strategy configuration.
This post explores how Execution Strategies work and demonstrates practical use cases.
Before Rails 7.1
Before Rails 7.1, migrations had limited customization options. A typical Rails migration looked like this:
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :name
t.timestamps
end
end
endWhen we called create_table, Rails used method_missing to forward the call directly to the database adapter.
The adapter would then execute SQL statements against the database.
This approach was simple but inflexible. There was no way to intercept or customize migration execution. We could not add logging, validation, or route commands to external systems.
After Rails 7.1
Rails 7.1 introduced an intermediary layer called the Execution Strategy. Migrations now delegate commands to this strategy object instead of going directly to the adapter.
By default, Rails uses ActiveRecord::Migration::DefaultStrategy.
This default strategy maintains backward compatibility by delegating to the database adapter.
Existing migrations continue to work without any changes.
The key improvement is that we can now define custom strategies. We can intercept migration commands and add custom behavior. This opens up many possibilities for controlling how migrations execute.
Real-World Examples
Let’s explore practical ways to use custom execution strategies.
Example 1: Logging Strategy
Sometimes we need to see what migrations would do without actually executing them. This is useful for testing migrations or understanding their impact.
Here is a strategy that logs migration commands instead of executing them:
# lib/logging_strategy.rb
class LoggingStrategy < ActiveRecord::Migration::ExecutionStrategy
# Intercept method calls that would normally go to the connection
def method_missing(method_name, *args, **kwargs, &block)
block_info = block ? " { block }" : ""
args_str = args.empty? ? "" : args.inspect
kwargs_str = kwargs.empty? ? "" : ", #{kwargs.inspect}"
message = "[Migration] Would call: #{method_name}(#{args_str}#{kwargs_str})#{block_info}"
Rails.logger.info(message)
# Return self for chaining
self
end
def respond_to_missing?(method_name, include_private = false)
true
end
endImportant: The strategy class must be loaded before config/application.rb runs.
We should place it in lib/ and require it explicitly:
# config/application.rb
require_relative "boot"
require "rails/all"
require_relative "../lib/logging_strategy" # Load before configuration
module YourApp
class Application < Rails::Application
# ... other config ...
config.active_record.migration_strategy = LoggingStrategy
end
endNow when we run a migration like:
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :name
t.string :email
t.timestamps
end
end
endWe can run it with:
rails db:migrateWe will see output like:
== 20251107093447 CreateUsers: migrating =======================================
-- create_table(:users)
[Migration] Would call: create_table([:users], {})
-> 0.0001s
== 20251107093447 CreateUsers: migrated (0.0046s) ============================The migration completes successfully without actually modifying the database schema. Each DDL command is intercepted and logged instead of being executed.
Example 2: Prevent Dangerous Operations
We can use custom strategies to prevent dangerous operations in production. This helps protect against accidental data loss.
Here is a strategy that blocks dangerous operations:
# lib/safe_strategy.rb
class SafeStrategy < ActiveRecord::Migration::DefaultStrategy
def drop_table(*args, **kwargs, &block)
raise "drop_table is not allowed in production!" if Rails.env.production?
super
end
def remove_column(*args, **kwargs, &block)
raise "remove_column is not allowed in production!" if Rails.env.production?
super
end
endThen load it in your application config:
# config/application.rb
require_relative "../lib/safe_strategy"
module YourApp
class Application < Rails::Application
config.active_record.migration_strategy = SafeStrategy
end
endNow if someone tries to drop a table or remove a column in production, they will get an error. This prevents accidental data loss.
Example 3: Forwarding Migrations to an External API
This example is inspired by how Shopify handles migrations at scale. Instead of running SQL directly, they serialize migration commands to JSON. These commands are sent to a service that coordinates changes across database shards.
Here is how we can implement a similar approach:
# lib/json_api_strategy.rb
require "net/http"
require "json"
class JsonApiStrategy < ActiveRecord::Migration::ExecutionStrategy
def method_missing(method_name, *args, **kwargs, &block)
payload = {
command: method_name,
args: args,
kwargs: kwargs,
migration: @migration.class.name,
version: @migration.version
}.to_json
uri = URI("https://my-ddl-service.internal/api/v1/migrations")
response = Net::HTTP.post(uri, payload, "Content-Type" => "application/json")
unless response.is_a?(Net::HTTPSuccess)
raise "Migration API failed: #{response.code} #{response.message}"
end
# Return self for method chaining
self
end
def respond_to_missing?(method_name, include_private = false)
true
end
endThen configure it:
# config/application.rb
require_relative "../lib/json_api_strategy"
module YourApp
class Application < Rails::Application
config.active_record.migration_strategy = JsonApiStrategy
end
endThis approach is powerful for sharded databases. It allows us to coordinate schema changes across multiple systems.
Configuration & Behavior
All custom strategies must inherit from ActiveRecord::Migration::ExecutionStrategy.
Rails uses ActiveRecord::Migration::DefaultStrategy by default.
Global Configuration (Rails 7.1+)
We can configure a strategy globally for all database adapters:
config.active_record.migration_strategy = MyCustomStrategyPer-Adapter Configuration (Rails 8.1+)
Rails 8.1 introduced the ability to configure different strategies for different database adapters. This is useful when working with multiple databases that need different migration behaviors.
# config/application.rb
module YourApp
class Application < Rails::Application
# Configure different strategies for different adapters
config.active_record.migration_strategy = {
postgresql: PostgresStrategy,
mysql2: MySQLStrategy,
sqlite3: SafeStrategy
}
end
endWe can also mix global and per-adapter configurations:
# Use LoggingStrategy for PostgreSQL, DefaultStrategy for everything else
config.active_record.migration_strategy = {
postgresql: LoggingStrategy
}This is particularly valuable in multi-database applications where each database might have different requirements. For example, we might want strict safety checks on our primary database but allow more flexibility on analytics databases.
The strategy gets access to both the migration object (via @migration) and the database connection (via @connection).
This provides full control over migration execution.
Version Compatibility
Execution Strategies were introduced in Rails 7.1.
Per-adapter configuration was added in Rails 8.1.
Real-World Use Cases
Beyond the examples above, teams are using execution strategies for:
Audit trails: Logging every schema change with timestamps and user information.
Approval workflows: Requiring manual approval before running certain migrations in production.
Multi-tenancy: Routing migrations to different databases based on tenant configuration.
Dry-run modes: Testing migrations against production-like data without making changes.
Integration with change management systems: Automatically creating tickets or notifications for schema changes.
