from the expand-contract pattern to rollback rehearsals
Running untested database migrations against production risks data loss, long downtime and silent inconsistencies. A clear test strategy built on the expand-contract pattern, rollback rehearsals and automated pipelines turns schema changes into a predictable process instead of a gamble.
Table of Contents
- 1. Why database migrations need testing at all
- 2. The test pyramid for schema migrations
- 3. Expand-contract pattern: checking compatibility in both directions
- 4. Testing migrations against a realistic data copy
- 5. Automated migration tests in the CI pipeline
- 6. Rollback strategies and their testability
- 7. Testing locking and performance impact
- 8. Detecting silent data corruption and data loss
- 9. Migration test strategies compared
- 10. Summary
- 11. FAQ
1. Why database migrations need testing at all
A schema migration looks simple at first glance: add a column, create an index, change a foreign key relationship. In practice, every migration is an intervention in a system that is being used in production at the same time, read by several application versions simultaneously, and may contain millions of rows. Anyone who deploys migrations untested is trusting that the syntax is correct without knowing whether the migration behaves the same under production load, with real data distributions and concurrent writes, as it did locally on an empty database.
The consequences of untested migrations range from a minutes-long table lock that blocks an entire checkout flow, to silent data loss because a NOT NULL column without a default value renders existing rows unusable. Testing database migrations systematically means playing through exactly these scenarios before the production deploy: with realistic data volumes, with concurrent application access, and with a clear rollback path in case something goes wrong. The sections below show how to structure, automate and integrate migration tests into existing pipelines.
2. The test pyramid for schema migrations
Migration tests follow the same basic principle as any test pyramid: many fast, isolated tests at the base, few expensive end-to-end tests at the top. At the lowest level are syntax and idempotency tests, which check whether a migration runs error-free against an empty database at all, and whether running the same migration a second time does not throw an error. These tests run in milliseconds and catch the most obvious errors before more complex tests even start.
At the middle level are migration tests against a database with representative test data: several thousand rows, edge cases like NULL values, very long strings, unicode characters and referential relationships across multiple tables. This is where it becomes visible whether a migration collides with real data, for example when a new UNIQUE constraint is added to a column that already contains duplicates in the test database. At the top of the pyramid is the full migration test against an anonymized copy of the production database, including timing, lock analysis and a rollback rehearsal. These tests are expensive and run less often, but they give the most reliable indication of how the migration will behave in production.
-- Layer 1: idempotency check for a migration
-- Run the migration twice against an empty schema, expect no error on the second run
BEGIN;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(20);
CREATE INDEX IF NOT EXISTS idx_customers_loyalty_tier ON customers (loyalty_tier);
COMMIT;
-- Layer 2: constraint collision check against representative test data
-- This must run BEFORE adding the constraint in production
SELECT email, COUNT(*) AS occurrences
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
-- Any row returned here means the UNIQUE constraint will fail on deploy
3. Expand-contract pattern: checking compatibility in both directions
The expand-contract pattern, sometimes called the parallel-change pattern, is the central technique for testing and rolling out database migrations without downtime. Instead of renaming or removing a column in one step, the change is broken into several steps that are each individually backward compatible: first add the new structure (expand), then populate both structures in parallel, then remove the old structure (contract). Each individual phase can be tested in isolation, because on its own it stays backward compatible with the currently running application version.
The decisive test case for this pattern is checking compatibility between two application versions: during a rolling deployment, the old and new code versions run simultaneously against the same database. A systematic migration test simulates exactly that by running the migration and then testing both application versions against the schema, not just the new one. Skip this check, and a compatibility break only surfaces in production once part of the fleet is already running the old code.
-- Expand phase: add new column, keep old column untouched
ALTER TABLE orders ADD COLUMN shipping_address_id BIGINT NULL;
-- Backfill phase: populate new column from old data, both stay in sync
UPDATE orders
SET shipping_address_id = (
SELECT id FROM addresses
WHERE addresses.order_id = orders.id
LIMIT 1
)
WHERE shipping_address_id IS NULL;
-- Contract phase: only after ALL app instances read the new column,
-- drop the old one in a separate, later migration
-- ALTER TABLE orders DROP COLUMN legacy_address_text;
4. Testing migrations against a realistic data copy
Checking migrations against an empty test database says little about how they behave against real data volumes. An ALTER TABLE that runs in milliseconds locally can take several minutes against a production table with fifty million rows, while holding an exclusive lock that blocks every write access. The most reliable way to catch that in advance is a regularly refreshed, anonymized snapshot of the production database used as a staging environment for migration tests.
When anonymizing, it matters that the statistical distribution of the data is preserved, not just the structure. A snapshot where every email address has been replaced with the same placeholder behaves differently under a new UNIQUE index than real production data with its natural distribution of duplicates and special characters. Tools like pg_sample for PostgreSQL, or custom anonymization scripts that deterministically replace values with realistic random values, keep the data distribution close to the original without exposing real personal data. Migration tests against such a snapshot reveal lock duration, IO load and memory consumption that stay invisible against an empty database.
5. Automated migration tests in the CI pipeline
Manually testing migrations does not scale once a team rolls out schema changes several times a week. The pragmatic approach is to run every migration automatically in the CI pipeline against a fresh database instance before a merge is allowed. A typical pipeline step starts a database container, applies all existing migrations up to the current state, loads a set of test data, and then runs the new migration, followed by a rollback and a second forward run.
This forward, rollback, forward cycle covers two common sources of error: missing or broken down migrations and migrations that are not idempotent on a second run. It is also worth adding an automated schema diff check after the migration, comparing the actual schema against the expected target state, to catch drift between migration scripts and the real database state early.
#!/usr/bin/env bash
# ci-migration-check.sh — run in CI before merging a schema migration
set -euo pipefail
echo "[1/4] Starting fresh database container"
docker compose up -d db
sleep 5
echo "[2/4] Applying all existing migrations up to current HEAD"
migrate -database "$DB_URL" -path ./migrations up
echo "[3/4] Loading representative seed data"
psql "$DB_URL" -f ./tests/fixtures/seed_representative.sql
echo "[4/4] Testing new migration: forward, rollback, forward again"
migrate -database "$DB_URL" -path ./migrations up 1
migrate -database "$DB_URL" -path ./migrations down 1
migrate -database "$DB_URL" -path ./migrations up 1
echo "Schema diff check against expected target state"
./tools/schema-diff.sh --expected ./schema/target.sql --actual "$DB_URL"
6. Rollback strategies and their testability
A migration without a tested rollback path is a blind flight in an emergency. A rollback is not automatically the simple inverse of the forward migration: if a column was removed, the rollback cannot restore its data unless it was copied into an archive table beforehand. Rollback scripts must therefore be tested just like the forward migration itself, using the same dataset, to check whether the previous state is actually reached after the rollback.
For irreversible operations such as dropping a column or changing a data type with a loss of precision, the rollback test must explicitly check which data would be lost in a rollback scenario and document that risk instead of silently accepting it. A proven pattern is to run destructive operations only in a later, separate migration, after the previous migration has already been stable in production for a while and a rollback would no longer make sense anyway.
-- Safe rollback pattern: archive data BEFORE the destructive forward migration
-- Forward migration (up):
CREATE TABLE customers_legacy_notes_archive AS
SELECT id AS customer_id, legacy_notes, NOW() AS archived_at
FROM customers;
ALTER TABLE customers DROP COLUMN legacy_notes;
-- Rollback migration (down): restore from the archive, not from thin air
ALTER TABLE customers ADD COLUMN legacy_notes TEXT NULL;
UPDATE customers
SET legacy_notes = archive.legacy_notes
FROM customers_legacy_notes_archive AS archive
WHERE archive.customer_id = customers.id;
-- Without the archive step, this rollback would only be able to add
-- back an empty column, not the original data
7. Testing locking and performance impact
Many migration failures do not come from wrong syntax but from unexpected locking behavior. An ALTER TABLE ADD COLUMN NOT NULL DEFAULT on a large table can, depending on the database system, trigger a full table rewrite and hold an exclusive lock for its entire duration. In PostgreSQL from version 11 onward, adding a column with a constant default is a pure metadata change without a table rewrite, but an additional CHECK constraint added with NOT VALID followed by VALIDATE CONSTRAINT behaves differently again and needs to be tested separately.
A systematic migration test therefore measures not only whether the migration completes successfully, but also how long it holds which lock type. Tools such as pg_stat_activity in PostgreSQL or SHOW PROCESSLIST in MySQL, observed while the migration runs alongside simulated application requests, show whether requests get blocked. For tables under heavy write load it is also worth testing with tools such as gh-ost or pt-online-schema-change for MySQL, which perform migrations through a shadow table without a long lock, but come with their own test cases, for example how they handle concurrent trigger changes.
8. Detecting silent data corruption and data loss
The most dangerous migration failures are not the ones that throw an error, but the ones that silently change or lose data. A type change from VARCHAR(255) to VARCHAR(50) without a prior check truncates longer values without necessarily reporting an error, depending on strict mode configuration. A conversion from DECIMAL to FLOAT introduces rounding errors that only surface weeks later in financial reports.
A systematic migration test should therefore compute and compare checksums over critical columns before and after the migration, for example with SUM(), COUNT(DISTINCT ...) or a hash over sorted rows. If the checksum differs after the migration even though no change to the values was intended, that is a strong signal of silent data corruption. This check can be integrated into the same CI pipeline that runs the forward and rollback tests, providing an additional, data-driven safety net beyond a pure syntax check.
-- Data integrity checksum before and after a migration
-- Run this before AND after applying the migration, compare results
SELECT
COUNT(*) AS row_count,
SUM(total_amount) AS sum_total_amount,
COUNT(DISTINCT customer_id) AS distinct_customers,
MD5(STRING_AGG(id::text, ',' ORDER BY id)) AS row_order_hash
FROM orders;
-- Any unexplained difference after the migration signals silent data loss
9. Migration test strategies compared
Not every test strategy fits every migration size. Small, additive changes need less effort than destructive operations on large production tables. The table below ranks the most important approaches by effort and by how much confidence they provide.
| Test strategy | Effort | Reliably catches | Recommended use |
|---|---|---|---|
| Syntax check on empty DB | Very low | Typos, invalid statements | Always, on every commit |
| Test with representative fixtures | Low | Constraint collisions, edge cases | For every new migration |
| Forward/rollback/forward cycle | Low to medium | Missing down migration, non-idempotency | In every CI pipeline |
| Test against anonymized production snapshot | Medium to high | Lock duration, runtime, IO load | Before critical migrations |
| Checksum comparison before/after | Low | Silent data corruption | For every data-changing migration |
In practice, teams combine several of these strategies depending on the risk of the migration. A simple index creation rarely needs more than a syntax check and a timing test, while a type change on a central table justifies the full scope, including a checksum comparison and a production snapshot. Documenting these escalation levels for the team avoids both unnecessary test effort on trivial changes and missing test effort on critical ones.
Mironsoft
Database migrations, test automation and CI/CD for Magento and beyond
Schema migrations that do not surprise you in production?
We build migration test pipelines that automatically check rollback capability, lock behavior and data integrity before a change ever reaches production.
Migration test setup
Anchoring forward-rollback cycles and checksum comparisons in the CI pipeline
Production snapshots
Anonymized, realistic test databases for meaningful migration tests
Zero downtime deploys
Expand-contract pattern for schema changes without any outage
10. Summary
Testing database migrations systematically means not treating them as a pure syntax exercise, but as an intervention in a production system with real data, real load and real application versions running in parallel. The test pyramid of syntax check, fixture test and production snapshot covers different risk levels. The expand-contract pattern makes migrations testable in both directions without forcing downtime.
Automated CI pipelines with forward-rollback-forward cycles and checksum comparisons catch the most common sources of error: missing down migrations, non-idempotency and silent data corruption. Anyone who additionally measures lock duration and performance against a realistic data copy goes into the production deploy with solid data instead of guesswork. The result: migrations turn from a risk factor into a predictable, repeatable part of the development process.
Testing Database Migrations — The Essentials at a Glance
Test pyramid
Syntax check, fixture test and production snapshot cover different risk levels of a migration.
Expand-contract
Break changes into backward-compatible steps, keep every phase individually testable.
CI automation
Forward-rollback-forward cycle in every pipeline to catch missing down migrations early.
Data integrity
Compare checksums before and after the migration to catch silent data corruption.