← Blog

Databases · Deployments · Schema Evolution

How Do You Change a Live Database Schema While Old and New Application Versions Are Both Running?

Zero-downtime schema evolution is a compatibility problem, not just a migration command. This article walks through expand-and-contract migrations, dual writes, resumable backfills, validation, feature-flagged cutovers, rollback windows, ORM hazards, online DDL, and the operational discipline required when old and new application versions share one production database.

Aashaya Aryal

September 27, 2026 · 23 min read

Several railway tracks splitting, crossing, and converging through an electrified rail corridor near a city.

Changing a database schema in development often feels instantaneous:

TEXT
change the model
→ run the migration
→ deploy the application
→ continue working

Production rarely behaves that way.

A new application version usually does not replace every old instance at once. During a rolling deployment, old and new binaries may serve traffic simultaneously. Background workers may restart later. Queued jobs may have been created by an older version. Read replicas can lag. A backfill may still be moving historical data while the new application has already started writing a different representation.

That creates a compatibility window in which the production system may look like this:

TEXT
Application v1
      ↓
   Database
      ↑
Application v2

Both versions need the database to make sense at the same time.

Now imagine version 2 expects:

TEXT
users.display_name

while version 1 still expects:

TEXT
users.full_name

A migration such as:

SQL
ALTER TABLE users
RENAME COLUMN full_name TO display_name;

may be logically correct for the new application and operationally disastrous for the old one. If your orchestrator intentionally keeps old instances alive until the new deployment proves healthy, the schema migration has just undermined the deployment strategy that was supposed to reduce risk.

That is the important shift in perspective:

A production schema migration is not merely a database change. It is a compatibility protocol between application versions, persistent data, background workers, replicas, and deployment infrastructure.

I have worked with deployment pipelines that combine schema migrations, application releases, smoke tests, and multiple data stores. The part that matters most is not whether the migration command exits successfully. It is whether the system remains correct while the migration is incomplete.

That middle state is where zero-downtime schema evolution becomes interesting.

The Compatibility Window Is the Real Migration

Suppose a service runs ten application instances and begins rolling out version 2.

For a period of time, production may contain both versions:

Diagram

Load Balancer

App v1

App v1

App v1

App v2

App v2

Shared Database

This is normal and usually desirable. Rolling deployments provide gradual replacement, health verification, easier rollback, and less risk than replacing the entire fleet at once.

The consequence is that the database often needs to support:

TEXT
old application
+
new application

simultaneously.

That means a schema change can be perfectly valid in its final form and still be unsafe to deploy directly.

The important question is not only:

TEXT
Will v2 work against the new schema?

It is also:

TEXT
Will v1 still work after the first migration?

Will v2 understand rows written by v1?

Will old workers understand data written by v2?

Can the application roll back while this schema exists?

Can a paused backfill resume safely tomorrow?

A production migration is therefore defined as much by its intermediate states as by its final state.

Destructive Changes Fail Earlier Than You Expect

Dropping a supposedly unused column is one of the simplest examples.

Suppose version 1 contains:

JAVA
@Entity
public class PullRequest {

    @Column(name = "legacy_status")
    private String legacyStatus;
}

The team concludes that legacy_status is no longer used in business logic, so the migration removes it:

SQL
ALTER TABLE pull_requests
DROP COLUMN legacy_status;

The DDL succeeds.

Then an ORM-generated query still does something equivalent to:

SQL
SELECT
    id,
    title,
    legacy_status,
    created_at
FROM pull_requests
WHERE id = ?;

The application fails even though nobody intentionally wrote new code that reads the old field.

This is not merely theoretical. GitHub reported two incidents in August 2025 involving production migrations that dropped columns application code was believed not to use directly, while ORM behavior still referenced those columns in certain queries. The resulting failures affected several production features, and GitHub temporarily restricted drop-column operations while strengthening safeguards.

The important lesson is subtle:

“Our new code does not use the column” is not enough evidence that the column can be removed.

Before destructive cleanup, I want confidence that the old representation is no longer required by:

TEXT
old application versions
ORM metadata
background workers
scheduled jobs
queued work
analytics and exports
replica consumers
administrative tools
rollback procedures

That is why destructive changes belong at the end of a migration.

Not the beginning.

Expand Before You Contract

A much safer general strategy is expand and contract.

Instead of forcing the system through one instantaneous transformation:

TEXT
OLD ─────────────────► NEW

introduce an intermediate representation that both worlds can understand:

TEXT
OLD
 ↓
EXPANDED
 ↓
MIGRATED
 ↓
NEW

Conceptually:

Diagram

Old Schema

Additive Schema

Old + New Data Maintained

New Application Uses New Model

Old Model Removed

The principle is straightforward:

Expansion should usually be backward compatible. Contraction happens only after old dependencies are gone.

Consider a column rename from:

TEXT
full_name

to:

TEXT
display_name

Instead of renaming the column immediately, add the new representation first:

SQL
ALTER TABLE users
ADD COLUMN display_name VARCHAR(255);

The database temporarily contains both:

TEXT
id
email
full_name
display_name

Old code still understands:

TEXT
full_name

and new code can begin understanding:

TEXT
display_name

Nothing has been destroyed.

Make the schema additive

The first migration should ideally create a new possibility without immediately imposing a new requirement.

That is why I would not necessarily begin with:

SQL
ALTER TABLE users
ADD COLUMN display_name VARCHAR(255) NOT NULL;

Every existing row currently lacks that value, and older application versions do not know they are expected to populate it.

Instead:

SQL
ALTER TABLE users
ADD COLUMN display_name VARCHAR(255);

creates the new representation without invalidating historical data or old writers.

This staging matters for constraints too. Shopify's Database Migrations team has documented how seemingly simple NOT NULL changes on live MySQL tables can interact with defaults, uniqueness, strict SQL modes, existing rows, and online schema-change tooling in surprising ways.

The first migration should create possibility.

The stronger invariant can come later.

Teach new code to understand both representations

Version 2 can read the new field while falling back to the old one:

TS
function getDisplayName(user: User): string {
  return user.displayName ?? user.fullName;
}

Conceptually:

TEXT
if new representation exists:
    use it
else:
    use old representation

This gives the new application backward compatibility with historical rows.

That matters because the schema may be expanded long before every existing record has been migrated.

Write both representations during the transition

New application versions can temporarily keep both columns current:

TS
await db.users.update({
  where: { id: userId },

  data: {
    fullName: input.name,
    displayName: input.name,
  },
});

During a mixed deployment:

TEXT
v1 writes old field
v2 writes old + new fields

The transition looks like:

Diagram

Application v1

Application v2

full_name

display_name

New data begins populating both representations naturally, but historical rows remain incomplete.

That is where the backfill begins.

Backfill historical data gradually

A naïve backfill might be:

SQL
UPDATE users
SET display_name = full_name
WHERE display_name IS NULL;

On a table containing 500 rows, that may be entirely reasonable.

On a table containing hundreds of millions of rows, it can produce long-running transactions, heavy write I/O, replication lag, WAL or binlog growth, lock pressure, cache churn, higher connection usage, and increased CPU demand.

A safer approach is to migrate bounded ranges:

SQL
UPDATE users
SET display_name = full_name
WHERE id > :last_id
  AND id <= :next_id
  AND display_name IS NULL;

The migration can then operate more like:

TEXT
batch
→ measure
→ yield
→ batch
→ measure
→ yield

A simple worker might look conceptually like:

TS
const BATCH_SIZE = 1000;

while (true) {
  const rows = await findNextBatch(BATCH_SIZE);

  if (rows.length === 0) {
    break;
  }

  await migrateBatch(rows);

  await sleep(100);
}

The exact sleep value should not become cargo-cult configuration.

The important property is:

Migration throughput should yield to production traffic.

Avoid OFFSET for large backfills

Naïve pagination can also become increasingly expensive:

SQL
SELECT *
FROM users
ORDER BY id
LIMIT 1000
OFFSET 10000000;

As the offset grows, the database may have to traverse increasing amounts of data simply to reach the next batch.

Keyset-style progress is usually a better fit when the schema and ordering permit it:

SQL
SELECT id
FROM users
WHERE id > :last_processed_id
ORDER BY id
LIMIT 1000;

Now:

TEXT
last_processed_id

is also a natural checkpoint.

That makes the worker easier to pause, resume, and reason about after a process restart.

Make the backfill idempotent

Production migrations should assume interruption.

A backfill may stop because of:

TEXT
process crash
deployment
operator pause
database failover
load-based throttling
maintenance

A migration that assumes it will execute exactly once from beginning to end is fragile.

Prefer operations that remain safe when repeated:

SQL
UPDATE users
SET display_name = full_name
WHERE id = :id
  AND display_name IS NULL;

If the row was already migrated, rerunning the step leaves it alone.

The safer mental model is:

TEXT
I may stop anywhere
and continue later.

That property becomes extremely valuable once the migration lasts hours or days instead of seconds.

Backfills Are Production Traffic

A migration may be described organizationally as "background work."

The database does not care.

To the database, a backfill consumes the same kinds of finite resources as customer traffic:

TEXT
connections
CPU
I/O
locks
buffer cache
replication bandwidth

GitHub reported a May 2026 outage in which a routine online schema migration against a large, heavily accessed table ran successfully for hours before normal production traffic increased toward a busier period. The combined load exhausted database connection capacity, increased query contention, and produced cascading timeouts across dependent features. GitHub's follow-up included lower-traffic migration windows, dynamic throttling, automated pauses based on database health, and improved migration-specific monitoring.

That is a much more useful model than:

TEXT
migration is running

The better model is:

TEXT
migration has a resource budget

Throttle against database health, not only time

A simple backfill might use:

TEXT
1000 rows
sleep 100 ms
1000 rows
sleep 100 ms

That is already better than one enormous transaction.

But database load at 3

AM may be nothing like database load at 2
PM.

A more adaptive worker can subordinate itself to live production health:

TS
while (hasMoreRows()) {
  const health = await databaseHealth();

  if (
    health.cpu > 75 ||
    health.connectionUsage > 70 ||
    health.replicationLag > MAX_REPLICATION_LAG
  ) {
    await sleep(5000);
    continue;
  }

  await migrateNextBatch();
}

The migration now behaves like a low-priority tenant of the production system.

Customer traffic wins.

You can take this further with automatic pause conditions around p99 query latency, connection utilization, replica lag, lock wait time, or write pressure.

The point is not that every migration needs a sophisticated control loop.

The point is that migration speed should be constrained by the health of the shared resource it is consuming.

Measure migration state, not merely worker health

A worker can report:

TEXT
job completed successfully

while the migration itself remains incomplete or inconsistent.

I care more about state-oriented measurements such as:

TEXT
rows_total
rows_migrated
rows_remaining
mismatch_count
backfill_rate
estimated_completion

alongside database health:

TEXT
DB CPU
DB connection usage
replication lag
lock wait time
query latency
application error rate

For example:

TEXT
Migration progress:       73%
Rows remaining:           28.4M
Mismatch count:           0
DB connections:           61%
Replica lag:              1.2s
p99 application query:    84ms

That tells me much more than:

TEXT
migration process is alive

The job is an implementation mechanism.

The state of the data is the migration.

Validate Before the New Representation Becomes Authoritative

Suppose the backfill reports:

TEXT
100%

I still would not immediately delete the fallback path.

First verify the state.

For the column example:

SQL
SELECT COUNT(*)
FROM users
WHERE display_name IS NULL;

Expected:

TEXT
0

Then compare representations:

SQL
SELECT COUNT(*)
FROM users
WHERE display_name IS DISTINCT FROM full_name;

Depending on the migration, validation can also include:

TEXT
checksums
sampled record comparisons
shadow reads
old-vs-new application output comparisons
mismatch counters
domain-specific invariants

Stripe used this kind of staged approach during a large migration involving hundreds of millions of subscription objects: dual writing, historical backfills, comparison experiments, gradual read and write transitions, and delayed removal of the old representation.

That leads to another useful principle:

A completed backfill is not the same as a validated backfill.

Switch reads gradually

Once validation looks good, the new representation can become authoritative.

That still does not require an all-at-once cutover.

A feature flag can keep code deployment separate from behavior activation:

TS
if (flags.readDisplayName) {
  return user.displayName;
}

return user.fullName;

Rollout might then proceed through:

TEXT
1%
5%
25%
50%
100%

while monitoring:

TEXT
errors
null rates
data mismatches
latency

The application already knows how to read the new schema, but production decides independently when that behavior becomes active.

That separation gives the migration another valuable control surface.

If the new read path produces unexpected results, the flag can often be disabled without rolling back the entire binary.

Stop writing the old representation

Once:

TEXT
new reads are stable
+
backfill is complete
+
old application versions are gone

the current application can stop maintaining:

TEXT
full_name

Now the system looks like:

Diagram

no new writes

Current Application

display_name

full_name

Deprecated

The old representation is no longer active.

But I still would not necessarily drop it immediately.

Rollback may still depend on it.

Rollback Determines When Cleanup Is Safe

Application rollback sounds simple:

TEXT
v2 broken
→ deploy v1

But version 1 may still require:

TEXT
full_name

If the migration already dropped that column, the old binary is no longer a valid rollback target.

That gives us an important rule:

A deployment is not safely reversible if the schema no longer supports the version you intend to roll back to.

It is useful to think about the migration in two phases.

Reversible phase

TEXT
old representation available
new representation available

Rollback is relatively straightforward because both versions still have the structures they require.

Irreversible phase

TEXT
old representation removed

Now recovery may require:

TEXT
forward repair

instead of:

TEXT
redeploy old binary

Destructive cleanup therefore deserves a different review threshold from additive expansion.

I would contract the schema only when conditions such as these are true:

TEXT
old application instances = 0
old workers = 0
old jobs that depend on the schema = 0
old code references = 0
rollback window expired
new data validated

Only then:

SQL
ALTER TABLE users
DROP COLUMN full_name;

The full migration becomes:

Diagram

Add display_name

Deploy code that reads both

Dual-write

Backfill

Validate

Switch reads to display_name

Stop writing full_name

Wait through rollback window

Drop full_name

That may require several deployments.

That is not wasted ceremony.

The extra steps are buying compatibility and reversibility.

Online DDL Still Has Operational Cost

Column migrations are not the only changes that need this kind of thinking.

Consider an index:

SQL
CREATE INDEX idx_orders_customer
ON orders(customer_id);

Logically, this is an additive change.

Operationally, it can still interfere with production.

PostgreSQL documents that a normal CREATE INDEX can block writes while the index is being built. On a heavily used production table, that may be unacceptable.

A live system may instead use:

SQL
CREATE INDEX CONCURRENTLY idx_orders_customer
ON orders(customer_id);

This allows writes to continue during creation, but the tradeoff is more work, a longer build, and additional failure behavior.

The useful equation is:

TEXT
less blocking
≠
free

An online migration still performs scans, consumes CPU and I/O, interacts with replicas, and competes with production workloads.

Constraints can be staged too

Suppose the desired final schema includes stronger validation.

PostgreSQL supports workflows in which constraint installation and validation can be separated. For example:

SQL
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id)
REFERENCES customers(id)
NOT VALID;

Then later:

SQL
ALTER TABLE orders
VALIDATE CONSTRAINT orders_customer_fk;

The initial step can prevent new invalid data without immediately scanning the entire existing table. Historical validation happens separately.

That illustrates a broader migration principle:

When the database supports it, separate “prevent new invalid state” from “prove all historical state is already valid.”

Large schema changes become easier to operate when enforcement and historical verification do not have to occur in one enormous step.

ORMs and Background Workers Extend the Compatibility Window

A migration plan can look perfect from the perspective of the primary API and still be unsafe because the API is not the only schema consumer.

ORMs hide database assumptions

Consider:

JAVA
@Entity
public class User {

    private UUID id;

    private String email;

    private String fullName;
}

Even after business logic stops calling:

JAVA
user.getFullName();

the ORM may still include the field in generated selects, expect it during result hydration, or retain it in model metadata.

That is the class of problem exposed by GitHub's 2025 drop-column incidents.

Schema cleanup therefore requires more than:

BASH
grep -R "full_name"

Generated SQL is part of the compatibility surface too.

Workers may outlive the application deployment

Suppose every API instance is now on version 2.

That sounds like the compatibility window has ended.

But what about:

TEXT
scheduled reports
Kafka consumers
SQS workers
cron jobs
data exports
admin tools
long-running batch processes

A queue may still contain work created by version 1. A delayed job may execute hours later. A worker may restart from an older deployment artifact. An export pipeline may access a read replica whose schema expectations differ from the API's.

The real compatibility window is therefore not:

TEXT
until the application rollout finishes

It is:

until every code path capable of touching that schema understands the new world.

That can be significantly longer than the visible deployment.

Dual Writes Create a Temporary Consistency Problem

Dual writing sounds simple:

TS
await writeOldModel(data);
await writeNewModel(data);

But suppose:

TEXT
writeOldModel = success
writeNewModel = failure

The two representations have now diverged.

If both fields exist in the same transactional database, update them in one transaction where possible:

TS
await db.transaction(async (tx) => {
  await tx.updateOldRepresentation(data);
  await tx.updateNewRepresentation(data);
});

That keeps the transitional representations aligned within one transactional boundary.

If the two models live in separate systems, the migration has become a distributed-write problem.

Now mechanisms such as these may become necessary:

TEXT
transactional outbox
event replay
reconciliation
repair jobs
idempotent consumers

A migration can therefore temporarily increase the consistency burden of the system.

That is another reason to keep the dual-write period finite. Transitional complexity should exist long enough to make the migration safe, not become the permanent architecture by accident.

Treat the Migration as a State Machine, Not a File

Migration frameworks often encourage a mental model such as:

TEXT
20260927_rename_full_name.sql

That naming is useful for versioning database changes.

It can also make a production migration feel more atomic than it actually is.

A more realistic process may be:

TEXT
Migration A
→ expand schema

Deploy A
→ understand both representations

Deploy B
→ dual-write

Backfill
→ migrate historical state

Validation
→ prove equivalence

Behavior rollout
→ switch reads

Deploy C
→ stop old writes

Wait
→ close rollback window

Migration B
→ remove old schema

The migration is therefore better understood as a state machine.

Diagram

Add compatible schema

Deploy transitional writers

Move historical data

Verify migrated state

Shift read behavior

Stop old writes

Rollback window closes

Remove old representation

Old

Expanded

DualWrite

Backfilling

Validated

NewReads

NewWritesOnly

CleanupReady

Final

Each state has different compatibility guarantees and different rollback options.

Thinking this way also changes review conversations. Instead of asking:

TEXT
Does this migration SQL look safe?

you can ask:

TEXT
What state are we entering?

Which binaries work in that state?

Which writers are allowed?

What can still be rolled back?

What proves it is safe to advance?

Those are much stronger operational questions.

Test Every Intermediate State That Production Can Reach

A migration test environment should not validate only:

TEXT
new schema + new application

The risky combinations are the transitional ones:

TEXT
old schema + new application

expanded schema + old application

expanded schema + new application

mixed old/new application fleet + expanded schema

new application + partially backfilled data

new schema + stale worker

The migration is safest when those intermediate states are intentional.

A useful rule is:

Every intermediate state that can exist in production should be considered a supported state during the migration.

For a larger change, I like making the compatibility contract explicit.

Application versionOld schemaExpanded schemaFinal schema
v1CompatibleCompatibleIncompatible
v2 transitionalNot intendedCompatibleCompatible
v3 finalIncompatibleCompatibleCompatible

The dangerous combination is immediately visible:

TEXT
v1 + final schema = incompatible

Therefore:

TEXT
the final destructive migration cannot run
while v1 can still exist in production

This is much clearer than:

“Make sure the migrations run in the correct order.”

It also avoids hiding important compatibility information behind symbols or emoji. The table states the deployment contract directly.

Schema Migrations Are Distributed Deployments

It may sound strange to describe a database migration as a distributed-systems problem.

Look at the participants:

Diagram

Database

Old API Instances

New API Instances

Background Workers

Event Consumers

Read Replicas

Analytics / Exports

Those components do not change state simultaneously.

They observe the migration at different times. Some cache schema or ORM metadata. Some have delayed work. Replicas may lag. A worker may restart hours later. An old binary may briefly return during rollback.

The migration therefore has to tolerate temporal skew between components.

That is distributed-system behavior.

The schema is shared state.

Application versions are independent participants with different expectations about that state.

The migration protocol has to preserve compatibility while those participants move from one interpretation to another.

Once you see the problem that way, patterns such as expand-and-contract, compatibility windows, feature flags, dual writes, idempotent backfills, and delayed destruction stop looking overly cautious.

They become the coordination mechanisms required to change a shared contract without stopping the system.

The Schema Is an API

We are usually cautious when changing:

TEXT
REST APIs
Kafka event schemas
public SDK interfaces

because some consumer may still depend on the old contract.

A production database schema deserves the same respect.

Its consumers may include:

TEXT
your API
another service
an ORM
a background worker
an ETL pipeline
an analytics query
an admin tool
a read replica consumer

Dropping a column is therefore conceptually similar to removing a field from a public API.

Maybe every consumer has migrated.

Maybe one has not.

The database does not know the difference until that consumer makes a request and fails.

Treating schema evolution as API evolution leads naturally to better questions:

TEXT
Who consumes this field?

Which versions are still deployed?

What compatibility do we promise during rollout?

How do we deprecate the old representation?

How do we know nobody depends on it anymore?

When does the breaking change become safe?

That mindset is more useful than thinking of a migration as a private implementation detail inside the database.

The Invariants I Want

For production schema evolution, I care about guarantees such as:

TEXT
Every application version serving traffic
can use the active schema.

Old application instances must remain functional
when expansion begins.

New application versions must understand
historical data.

Backfills must be resumable and idempotent.

Migration work must not consume unlimited
production database capacity.

The new representation must be validated
before becoming authoritative.

Application rollback must remain possible
through the defined rollback window.

Destructive cleanup must wait until all old
readers and writers are gone.

Every production-reachable intermediate state
must have defined compatibility.

Migration health and migration progress must be
observable independently from application deployment.

Those are stronger guarantees than:

TEXT
migration command exited 0

because a successful SQL statement says very little about whether the surrounding deployment remained safe.

The migration is successful only when the system moves from the old contract to the new one without losing compatibility, correctness, observability, or the rollback guarantees we intended to keep.

Final Thought

Database migrations become dangerous when we imagine them as instantaneous.

They are not.

A production migration has a beginning, a middle, and an end. During the middle, old code may still be running while new code has already arrived. Historical rows may still use the old representation while newer rows contain both. Backfills may be active. Replicas may lag. Workers may be processing older jobs. Rollback may still be necessary.

That intermediate state is where most of the engineering lives.

The safest migrations therefore do not force the entire system to jump directly from one world to another. They expand first, preserve compatibility, move data gradually, observe the impact, validate the new representation, shift behavior deliberately, wait through the rollback window, and only then remove the old world.

That can feel excessively cautious when the actual migration is only:

SQL
ALTER TABLE ...

But the SQL is not the system.

The system is every process that depends on the schema while that SQL is changing its contract.

So the real question is not:

“Will this migration execute successfully?”

It is:

“Can every version of the system remain correct while the migration is incomplete?”

If the answer is yes, you have something much more useful than a migration that works.

You have a migration that can be safely operated.

References

  • GitHub — GitHub Availability Report: August 2025. Two production incidents were caused by dropped database columns that GitHub's ORM still referenced, demonstrating how destructive schema changes can break code even when developers believe the column is unused.

  • GitHub — GitHub availability report: May 2026. A routine online schema migration on a large, heavily accessed table combined with normal traffic to saturate connection capacity and trigger cascading timeouts. GitHub's follow-up included dynamic migration throttling, load-aware circuit breakers, and improved migration monitoring.

  • Stripe Engineering — Online migrations at scale. Stripe describes migrating hundreds of millions of subscription objects using staged dual writes, historical backfills, production comparison experiments, incremental read/write transitions, and delayed cleanup of the old representation.

  • Shopify Engineering — Safely Adding NOT NULL Columns to Your Database Tables. Shopify's Database Migrations team explores backward compatibility and data-loss risks when adding constraints to live MySQL tables through online schema-change tooling.

  • PostgreSQL Documentation — CREATE INDEX. Explains the operational difference between normal and concurrent index creation: standard index builds block writes, while concurrent builds allow them at the cost of additional work and complexity.

  • PostgreSQL Documentation — ALTER TABLE. Documents staged constraint validation using NOT VALID and VALIDATE CONSTRAINT, allowing enforcement for new data to be separated from validation of existing rows.