If you’ve ever run a migration at 2 a.m. while holding your breath and refreshing a dashboard, welcome to the club. We’ve all been there: the table has 300 million rows, the change looks innocent, and yet somehow the deployment window stretches from “five minutes” into “why is the CEO in the Slack channel?”
Database migrations are the plumbing of backend engineering. Nobody writes poetry about them until they explode. And in a world where customers expect 24/7 uptime and deployments happen dozens of times a day, the old “stop the app, run DDL, pray” playbook simply doesn’t cut it.
Today, we’re going to look at three techniques that separate the pros from the sleep-deprived: expand/contract schema changes, trigger-based synchronization, and shadow reads. Used separately, each is useful. Combined, they let you refactor a production database the way a surgeon replaces a heart valve — while the patient is still running a marathon.
Why Zero-Downtime Migrations Matter More Than Ever
Let’s start with a truth that’s easy to forget: your application and your schema are not two isolated systems. They are one moving organism. In modern CI/CD pipelines, multiple versions of your application can be live at the same time. A rolling deployment means Version A and Version B share the database simultaneously. If Version B needs a column that Version A doesn’t understand — or vice versa — someone is going to crash.
The foundational strategy for surviving this is to treat schema changes as additive and backward-compatible. That is the heart of the expand/contract pattern: every schema change must first expand the schema to support both old and new behavior, then later contract by removing the parts you no longer need. This lets old and new application versions coexist during a phased deployment sequence.
But expand/contract only gives you the runway. You still need to keep data in sync across the old and new shapes while you transition, and you need to verify that the new shape actually behaves correctly before you make it primary. That is where triggers and shadow reads come in.
Large engineering organizations such as Stripe, Shopify, and GitHub treat migrations as multi-step workflows rather than single DDL operations. They don’t run one big ALTER TABLE and call it done. They sequence changes, validate them, and roll back gracefully if anything smells wrong. The lesson is simple: zero-downtime migrations are fundamentally socio-technical processes. They require coordination between application deployments and schema changes, making backward and forward compatibility non-negotiable design constraints.
The Expand/Contract Pattern: Add First, Delete Later
Let’s unpack the pattern in detail, because everything else rests on it.
In a traditional migration, you change the schema and the application at the same time. The old schema is gone; the new application expects it; there is no overlap. That works in a single-step deployment, but it is brittle and downtime-prone.
Expand/contract replaces that single destructive step with a sequence of safer steps:
Expand the schema. Add the new columns, tables, or indexes you need, but do not remove anything yet. The old application version can still read and write the old structure, and the new application version can read and write the new structure.
Update the application to write both shapes. During the transition, the application writes data to the old schema and the new schema. This is often called dual writing. Reads may still come from the old schema.
Backfill and synchronize data. Any existing data must be copied or transformed into the new shape, and ongoing writes must be kept in sync.
Cut reads over to the new schema. Once you have verified correctness and performance, you switch read traffic to the new schema.
Stop writing to the old schema. Remove the old write path from the application.
Contract the schema. Only after nothing is using the old columns or tables do you drop them.
The key insight here is reversibility. At almost every stage, you can roll back to the previous state without data loss, because the old schema still exists and the data is still there.
For example, imagine you are renaming the email column to email_address. Instead of running RENAME COLUMN, you add email_address, copy existing values, dual-write both, cut reads to email_address, stop writing to email, and only then drop email. It is more steps than a single DDL command, but it is also the reason your users don’t see a 503 while you are doing it.
Trigger-Based Synchronization: The Database’s Intern
Dual writing from the application is clean when you control every code path, but real systems have cron jobs, event processors, legacy scripts, third-party integrations, and that one Python script Dave wrote in 2019 that nobody wants to touch. If even one writer only knows the old schema, your new schema will drift.
That is where trigger-based synchronization shines. A database trigger is a piece of logic that runs automatically when a specified change happens to a table. You can use triggers to mirror writes from the old schema shape to the new one, or vice versa, at the database layer.
Trigger-based synchronization provides a reliable mechanism to mirror writes between old and new schema shapes. But — and this is a big but — it requires careful handling of idempotency, ordering, and rollback procedures to avoid data divergence or infinite loops.
Here are the practical concerns:
Idempotency. The trigger must be safe to run multiple times for the same logical event. If a row is updated, the trigger should update the corresponding new row if it exists and insert it if it does not, ideally in an idempotent way.
Ordering. If writes to the old schema trigger updates to the new schema, and writes to the new schema also trigger updates to the old schema, you can create an infinite loop unless you guard against it. A common technique is to use a session variable or a sentinel column to suppress recursive triggers.
Performance. Triggers run in the same transaction as the original write. Heavy trigger logic can increase write latency and lock contention. Keep triggers lean and avoid complex business logic inside them.
Rollback. Triggers are part of the schema. You need a tested procedure to disable or reverse them if the cutover fails. If the trigger has already propagated a bad write, you must know how to reconcile.
Despite these caveats, triggers are a powerful safety net. They let you guarantee synchronization even for writers you cannot fully control, and they keep the old and new schema shapes consistent while your application code slowly migrates.
Shadow Reads and Dual Writes: Test in Production (Responsibly)
Now we get to my favorite part: the moment when you peek into the future without actually stepping into it.
Dual writes means the application writes to both the old and new schema at the same time. This keeps them synchronized through application logic rather than triggers. It is a common companion to expand/contract because it gives you real, production write traffic into the new schema.
Shadow reads go a step further. When you perform a read from the old schema, you also perform the equivalent read from the new schema, but you do not return the new result to the user. Instead, you compare the two results asynchronously and log any differences. This lets you measure correctness and performance before promoting the new schema to primary.
Shadow reads and dual writes enable safe validation of a new schema by asynchronously comparing query results from both old and new data stores. This is the empirical layer of zero-downtime migration. You are not guessing whether the new schema works; you are proving it with production traffic.
The comparison logic must be thoughtful. Timestamps may differ by microseconds. Floats may have rounding differences. Ordering may be unstable if you do not include explicit sort keys. Your shadow read comparator should normalize results and define acceptable tolerance.
You also need telemetry. Count mismatches, latency percentiles, and error rates. If the new schema is slower, you want to know before it becomes primary. If it returns wrong data, you want to know before a customer notices.
A typical cutover plan looks like this:
Deploy dual writes so both schemas receive live traffic.
Backfill historical data.
Enable shadow reads and monitor comparison metrics.
Fix any discrepancies.
Switch reads to the new schema.
Disable dual writes to the old schema.
Contract the old schema.
This sequence is why major teams treat migrations as multi-step workflows. The actual DDL is maybe 10 percent of the work. The rest is observation, verification, and coordination.
A Concrete Walkthrough with Python
Let’s make this concrete. Suppose you have a users table with a single full_name column, and you want to split it into first_name and last_name. We will simulate the expand/contract pattern, trigger-based synchronization, and shadow reads using Python and SQLite.
First, the initial schema:
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("PRAGMA foreign_keys = ON")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
full_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()Now we expand the schema by adding the new columns:
cursor.execute("""
ALTER TABLE users
ADD COLUMN first_name TEXT;
""")
cursor.execute("""
ALTER TABLE users
ADD COLUMN last_name TEXT;
""")
conn.commit()Next, we backfill existing rows. In production, you would do this in batches to avoid long locks:
def backfill_name_split(cursor):
cursor.execute("SELECT id, full_name FROM users WHERE first_name IS NULL")
for row_id, full_name in cursor.fetchall():
parts = full_name.split(maxsplit=1)
first = parts[0]
last = parts[1] if len(parts) > 1 else ""
cursor.execute("""
UPDATE users
SET first_name = ?, last_name = ?
WHERE id = ?
""", (first, last, row_id))
backfill_name_split(cursor)
conn.commit()Now we add triggers to keep the old and new columns synchronized. We use a sentinel session variable to prevent infinite recursion:
cursor.executescript("""
CREATE TRIGGER trg_users_sync_old_to_new
AFTER UPDATE OF full_name ON users
WHEN IFNULL(current_setting('syncing'), '0') = '0'
BEGIN
UPDATE users
SET first_name = substr(NEW.full_name, 1, instr(NEW.full_name || ' ', ' ') - 1),
last_name = substr(NEW.full_name || ' ', instr(NEW.full_name || ' ', ' ') + 1)
WHERE id = NEW.id;
END;
CREATE TRIGGER trg_users_sync_new_to_old
AFTER UPDATE OF first_name, last_name ON users
WHEN IFNULL(current_setting('syncing'), '0') = '0'
BEGIN
UPDATE users
SET full_name = NEW.first_name || ' ' || NEW.last_name
WHERE id = NEW.id;
END;
""")
conn.commit()Note: SQLite uses current_setting semantics differently than PostgreSQL; in production on Postgres you would use SET LOCAL my_app.syncing = '1' inside the trigger function. The concept is the same: prevent recursive loops.
Now we simulate dual writes from two application versions. The old code writes full_name; the new code writes first_name and last_name:
def insert_user_old(cursor, full_name):
cursor.execute("""
INSERT INTO users (full_name) VALUES (?)
""", (full_name,))
def insert_user_new(cursor, first_name, last_name):
cursor.execute("""
INSERT INTO users (first_name, last_name, full_name)
VALUES (?, ?, ?)
""", (first_name, last_name, f"{first_name} {last_name}"))Finally, we implement a shadow read comparator. The application reads from the old columns, but also queries the new columns in the background and compares results:
def read_user_old(cursor, user_id):
cursor.execute("SELECT id, full_name FROM users WHERE id = ?", (user_id,))
return cursor.fetchone()
def read_user_new(cursor, user_id):
cursor.execute("""
SELECT id, first_name, last_name
FROM users WHERE id = ?
""", (user_id,))
return cursor.fetchone()
def shadow_read_compare(cursor, user_id):
old_row = read_user_old(cursor, user_id)
new_row = read_user_new(cursor, user_id)
old_full = old_row[1]
new_full = f"{new_row[1]} {new_row[2]}".strip()
if old_full != new_full:
print(f"MISMATCH for user {user_id}: old='{old_full}' new='{new_full}'")
return False
print(f"MATCH for user {user_id}: '{old_full}'")
return TrueThis small script captures the essence of the pattern. In a real system, the shadow comparison would run in a background job, emit metrics, and route alerts to a dashboard. The triggers would be guarded by session flags. And the backfill would run in idempotent, resumable batches.
Tooling That Saves Your Weekend
Philosophy is great, but at some point you need tools that actually run the commands. The choice of tooling significantly influences the feasibility of zero-downtime migrations by automating schema changes, online table rebuilds, and deployment sequencing.
Here are the heavy hitters:
pt-online-schema-change (Percona Toolkit). A classic for MySQL. It creates a shadow copy of the table, applies the schema change to the copy, synchronizes deltas using triggers, and then swaps the tables. It avoids long locks on large tables.
gh-ost. GitHub’s online schema change tool for MySQL. Instead of triggers, it uses a binary log stream to capture changes. This reduces trigger overhead and makes it easier to throttle and pause migrations mid-flight.
Flyway and **Liquibase.These are schema version control systems. They do not perform online table rebuilds themselves, but they are essential for sequencing migrations, tracking which scripts have run, and coordinating multi-step expand/contract workflows across environments.
Reshape and pgroll. Newer tools designed specifically for expand/contract migrations on PostgreSQL. They manage multiple schema versions at the database level, making it easier to keep old and new application versions happy.
AWS Database Migration Service (DMS) and similar platforms. Useful when you are migrating across database engines or regions, often combining ongoing replication with cutover tooling.
No single tool does everything. The real pros combine them: use pt-online-schema-change or gh-ost for the low-level table rebuild, Flyway or Liquibase for migration sequencing, and custom shadow-read infrastructure for validation.
Rollback: The Feature You Hope to Never Use
Here is the uncomfortable truth: a migration is not done when the new schema is live. A migration is done when you are confident you can undo every step of it.
Rollback safety is a cross-cutting concern that connects all three techniques. Each phase of an expand/contract migration, trigger synchronization setup, and shadow-read validation must be reversible without data loss.
Before you start, ask these questions:
Can I revert the application to the previous version and still read the old schema?
If the trigger is removed, will the old schema still contain the correct data?
If I stop dual writes to the new schema, will the old schema continue to work?
Do I have a point-in-time backup or logical restore path?
Can I pause the migration and resume it later?
Write down the rollback steps. Test them in staging. If your answer to any of these questions is “I think so,” you are not ready.
The best migration plans read like a choose-your-own-adventure book, with a happy path and several sad paths. The teams that sleep well are the ones that have rehearsed the sad paths.
Closing Thoughts: Migrate Like You Mean It
Zero-downtime database migrations are not magic. They are discipline. They are the art of making big changes in small, reversible steps and trusting the evidence before you trust the cutover.
Use expand/contract to give yourself a safe runway. Use triggers or dual writes to keep both schema shapes consistent. Use shadow reads to prove the new shape works under real traffic. And always, always have a rollback plan that you have actually tested.
The next time someone asks you to “just run an ALTER TABLE real quick,” you can smile knowingly, crack your knuckles, and say, “Sure — let me show you the plan.”
Keep building, keep shipping, and may your migrations be boring.
Warmly,
The Backend Developers
P.S. — If this post saved you from a midnight outage, come back tomorrow. We’re just getting started. Follow along, share it with your favorite DBA, and let’s make the backend world a little less terrifying together.









