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
endStep 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
endStep 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
endStep 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
endStep 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
endStep 5: Remove old column and clean up model
class RemoveOldEmailColumn < ActiveRecord::Migration[7.0]
def change
remove_column :users, :email, :string
end
endclass User < ApplicationRecord
# Remove ignored_columns and compatibility methods.
# email_address is now the canonical column; use standard Rails accessors.
endExample: 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
endStep 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
endStep 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
endStep 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
endStep 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
endStep 5: Remove old column and clean up model
class RemoveAgeString < ActiveRecord::Migration[7.0]
def change
remove_column :users, :age_string, :string
end
endclass User < ApplicationRecord
# Remove ignored_columns and compatibility methods.
# age_int is now the canonical column; use standard Rails accessors.
endMigration 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
- Tim Wellhausen, “Expand and Contract: A Pattern to Apply Breaking Changes to Persistent Data with Zero Downtime” — https://www.tim-wellhausen.de/papers/ExpandAndContract/ExpandAndContract.html
- Danilo Sato, “Parallel Change” — https://martinfowler.com/bliki/ParallelChange.html
- Albert Starreveld / VX Company, “Deploying backward-compatible schema changes, continuously” — https://medium.com/vx-company/expand-and-contract-d5635e31776e
Rails Tools
- Strong Migrations — https://github.com/ankane/strong_migrations
CI Analysis Tools
- Squawk (PostgreSQL migration linter) — https://github.com/sbdchd/squawk
Background Reading
- Atlas, “The Myth of Down Migrations” — https://atlasgo.io/blog/2024/04/01/migrate-down
- Atlas, “The Hard Truth about GitOps and Database Rollbacks” (KubeCon NA 2024) — https://atlasgo.io/blog/2024/11/14/the-hard-truth-about-gitops-and-db-rollbacks