Skip to content
Database Migration Patterns Reference

Database Migration Patterns Reference

This document provides implementation details for safe database migration patterns. It is the practical companion to the Intentional Release Guidelines, which explains the principles behind forward-only migrations and the Expand-Contract pattern. For the release workflow and CI pipeline details, see the Intentional Release Workflow Guide.


The Expand-Contract Pattern

The Expand-Contract pattern (also called Parallel Change) allows breaking database changes to be deployed safely across multiple releases. Each release in the sequence is backward-compatible, independently deployable, and independently rollback-safe. The pattern was originally documented by Tim Wellhausen and Danilo Sato.

The core idea: never make a breaking change in a single step. Instead, expand the schema by adding the new structure alongside the old, migrate data, switch over, and then contract by removing the old structure.

The Five Steps

Step 1: Expand. Add the new structure (columns, tables) to the database. Update application code to write to both old and new structures. Continue reading from the old structure. The new structure is not yet trusted as the source of truth.

Step 2: Backfill. Migrate existing data from the old structure into the new. Run this as a background process, batched and throttled. The application still reads from the old structure, so correctness is not affected.

Step 3: Switch reads. Data now exists consistently in both structures. Switch the application to read from the new structure. Continue writing to both. This is the key transition: the new structure becomes the source of truth.

Step 4: Stop writing to old structure. The old structure is now neither read from nor written to. From this point, rolling back requires restoring data to the old structure, which is harder but rarely needed since the new structure is fully validated.

Step 5: Contract. Remove the old structure. This is the only irreversible step, and it should only happen after the new structure has been running in production long enough to confirm correctness.

How Steps Map to Releases

Step Release Type Rollback Safety
1. Expand MINOR (additive) Safe: drop the new structure
2. Backfill MINOR (data-only) Safe: clear new structure, restart
3. Switch reads MINOR (code change) Safe: switch reads back to old structure
4. Stop old writes MINOR (code change) Harder: old structure stops receiving updates
5. Contract MAJOR (destructive) Irreversible: old structure is gone

The key insight: only Step 5 is a MAJOR release. Steps 1 through 4 are all MINOR, meaning the risk of the overall change is spread across multiple small, safe deployments rather than concentrated in a single dangerous one.


Single-Schema Implementation for Rails

Our applications use a single database schema per service. The Expand-Contract pattern works naturally with this setup by using application-level compatibility layers rather than dual schemas.

Key Principles

Additive changes first. Always add new columns and tables before removing old ones. Never remove or rename in the same deployment as application changes that depend on them.

Application-level compatibility. Use model methods to handle the transition logic. Rails models act as the compatibility layer between old and new schema structures.

Phased rollouts. Each step works with both old and new application versions. Structure changes so each database migration is backward-compatible with the currently-running code.

Example: Column Rename (email → email_address)

This is the most common Expand-Contract scenario: renaming a column while keeping the application functional throughout.

Step 1: Add the new column (nullable)

class AddEmailAddress < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :email_address, :string
    add_index :users, :email_address
  end
end

Step 1 (app): Dual-write, read from old

class User < ApplicationRecord
  def email
    read_attribute(:email)
  end

  def email=(value)
    write_attribute(:email, value)
    write_attribute(:email_address, value)
  end
end

Step 2: Backfill existing data

class BackfillEmailAddress < ActiveRecord::Migration[7.0]
  disable_ddl_transaction!

  def up
    User.unscoped.in_batches(of: 1000) do |batch|
      batch.where(email_address: nil).where.not(email: nil).update_all(
        "email_address = email"
      )
      sleep(0.01)
    end
  end
end

Step 3 (app): Switch reads to new column

class User < ApplicationRecord
  def email
    read_attribute(:email_address)
  end

  def email=(value)
    write_attribute(:email_address, value)
    write_attribute(:email, value)  # Keep for rollback safety
  end
end

Step 4 (app): Stop writing to old column

class User < ApplicationRecord
  self.ignored_columns += [:email]

  def email
    read_attribute(:email_address)
  end

  def email=(value)
    write_attribute(:email_address, value)
  end
end

Step 5: Remove old column and clean up model

class RemoveOldEmailColumn < ActiveRecord::Migration[7.0]
  def change
    remove_column :users, :email, :string
  end
end
class User < ApplicationRecord
  # Remove ignored_columns and compatibility methods.
  # email_address is now the canonical column; use standard Rails accessors.
end

Example: Type Change (string → integer)

When a column’s data type needs to change, the same pattern applies.

Step 1: Add new typed column

class AddAgeInteger < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :age_int, :integer
  end
end

Step 1 (app): Dual-write, read from old

class User < ApplicationRecord
  def age
    age_string&.to_i
  end

  def age=(value)
    self.age_string = value.to_s if value.present?
    self.age_int = value.to_i if value.present?
  end
end

Step 2: Backfill with validation

class BackfillAgeInteger < ActiveRecord::Migration[7.0]
  disable_ddl_transaction!

  def up
    User.unscoped.in_batches(of: 1000) do |batch|
      batch.where(age_int: nil).where.not(age_string: [nil, ""]).find_each do |user|
        if user.age_string.match?(/\A\d+\z/)
          user.update_column(:age_int, user.age_string.to_i)
        end
      end
      sleep(0.01)
    end
  end
end

Step 3 (app): Switch reads to new column

class User < ApplicationRecord
  def age
    read_attribute(:age_int)
  end

  def age=(value)
    self.age_int = value.to_i if value.present?
    self.age_string = value.to_s if value.present?  # Keep for rollback safety
  end
end

Step 4 (app): Stop writing to old column

class User < ApplicationRecord
  self.ignored_columns += [:age_string]

  def age
    read_attribute(:age_int)
  end

  def age=(value)
    write_attribute(:age_int, value.to_i) if value.present?
  end
end

Step 5: Remove old column and clean up model

class RemoveAgeString < ActiveRecord::Migration[7.0]
  def change
    remove_column :users, :age_string, :string
  end
end
class User < ApplicationRecord
  # Remove ignored_columns and compatibility methods.
  # age_int is now the canonical column; use standard Rails accessors.
end

Migration Analysis Tooling

Development-Time Guidance

Strong Migrations (Rails) detects dangerous migration patterns at development time and suggests safer alternatives. It catches issues like adding columns with default values to large tables, creating non-concurrent indexes, and changing column types unsafely.

# Gemfile
gem 'strong_migrations'

Strong Migrations provides immediate feedback in development. It does not run in CI. Its role is to guide developers toward safe patterns before code is even committed.

CI Pipeline Analysis

Squawk lints PostgreSQL migration SQL in CI. It analyzes the actual SQL that migrations will execute and reports risks like table locks, unsafe type changes, and missing concurrent index creation.

In our CI pipeline, migration DSLs are converted to raw SQL, which is then linted by Squawk. The analysis produces a risk classification, a recommended version bump, and any warnings. These results feed into the draft release for the team to review.

The Two Layers

Development-time tools (Strong Migrations) and CI tools (Squawk) serve different purposes:

Development time provides immediate, interactive feedback while writing code. It catches common mistakes before they’re committed.

CI pipeline provides authoritative analysis on the actual SQL that will run. It produces the risk classification and version bump recommendation that appears in the draft release. This is the analysis the team reviews when deciding whether to publish a release.

Both layers are advisory, not blocking. They inform decisions rather than prevent merges or deploys.


References

Pattern Documentation

Rails Tools

CI Analysis Tools

Background Reading