Customizing Rails Migrations with Execution Strategies

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
end

When 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
end

Important: 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
end

Now 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
end

We can run it with:

rails db:migrate

We 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
end

Then 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
end

Now 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
end

Then configure it:

# config/application.rb
require_relative "../lib/json_api_strategy"

module YourApp
  class Application < Rails::Application
    config.active_record.migration_strategy = JsonApiStrategy
  end
end

This 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 = MyCustomStrategy

Per-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
end

We 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.

References

Need help on your Ruby on Rails or React project?

Join Our Newsletter