Skip to main content
Database Migrations Without Downtime in .NET
AI & Technology

Database Migrations Without Downtime in .NET

10 min read
468 views
Share:

Every team I have led eventually hits the same wall: the schema needs to change, the business cannot tolerate a maintenance window, and the database underneath a live payments platform is the least forgiving place to make a mistake. In regulated fintech the cost of getting this wrong is not measured in a few seconds of user frustration. It is measured in failed settlements, broken reconciliation, and a compliance conversation nobody wants to have.

Database Migrations Without Downtime in .NET
Database Migrations Without Downtime in .NET

Over the years I have settled on a fairly disciplined approach to schema evolution in .NET, built around the idea that a migration is not a single event but a sequence of small, individually safe steps. This post is the playbook I hand to engineers joining my teams. It is opinionated, it leans on Entity Framework Core where that helps, and it is deliberately conservative because conservatism is the right default when money is moving.

Why Downtime Is Not an Option

When I started out, a Sunday-night maintenance window was a perfectly respectable way to ship a schema change. You took the system offline, ran the script, verified, and came back up before the markets opened. That world is gone. Our customers integrate over APIs that they expect to be available continuously, and our own internal services assume the database is always there. A planned outage is a contractual and reputational liability, not a convenience.

The deeper reason is architectural. Modern .NET systems are rarely a single application talking to a single database. They are a fleet of services, background workers, and scheduled jobs, often deployed independently. The moment you accept that you cannot stop all of them at once, you are forced to accept that old code and new code will run against the same database at the same time. Every migration strategy I trust flows from that single constraint.

Once you internalize that constraint, the problem reframes itself. You are no longer asking how to change the schema. You are asking how to change it in a way that is compatible with every version of the application that might be running during the rollout. That is a harder question, but it is the right one.

The Expand and Contract Pattern

The single most valuable idea in zero-downtime migrations is expand and contract, sometimes called the parallel change. Instead of mutating a column or table in place, you split the change into three distinct deployments: expand the schema additively, migrate the code and backfill the data, then contract by removing what is now unused. Each deployment is independently reversible, and at no point does the database hold a shape that the currently running code cannot understand.

Consider renaming a column from customer_name to legal_entity_name. The naive approach is a single rename, which instantly breaks any deployed code still selecting the old name. The expand-and-contract version looks like this:

  • Expand: add the new legal_entity_name column, nullable, with no constraints. Old code ignores it; new code can begin writing to it.
  • Migrate: deploy code that writes to both columns and reads from the new one with a fallback, then backfill historical rows in batches.
  • Contract: once every instance reads and writes only the new column, drop the old one in a final, separate deployment.

It feels like more work, and it is. But each step is small enough to reason about, test, and roll back. I would rather ship three boring deployments than one heroic one that pages the on-call engineer at three in the morning.

EF Core Migrations as the Baseline

In .NET, EF Core migrations are my default tooling, not because they are magic but because they give you a versioned, reviewable, source-controlled history of schema change. The mistake teams make is treating the generated migration as the finished product. The scaffolded code is a starting draft. You must read it, and frequently you must rewrite it, because EF does not know which operations will lock a table under production load.

I insist that every migration be split so that schema changes and data changes never share a transaction with anything long-running. I also require that destructive operations live in their own migration, applied only after the corresponding code has been fully rolled out. Here is the shape of an additive expand step that I would happily approve:

public partial class AddLegalEntityName : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Additive and nullable: safe to apply while old code runs.
        migrationBuilder.AddColumn<string>(
            name: "legal_entity_name",
            table: "customers",
            type: "varchar(256)",
            nullable: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "legal_entity_name",
            table: "customers");
    }
}

Notice what is absent: no NOT NULL, no default that forces a table rewrite, no index built inline. Those come later, deliberately, once the column exists and is populated. The migration that I will not approve is the one that tries to do everything at once.

Backfilling Data in Batches

The expand step is cheap. The backfill is where the danger lives, because a single UPDATE across a large table will take a lock, blow up your transaction log, and stall every concurrent writer. I have watched a well-meaning engineer run an unbatched update on a forty-million-row ledger table and effectively take the platform down without ever touching the application code.

The discipline is to backfill in bounded batches, with a delay between them, and to make the operation idempotent so it can be safely restarted. I keep this logic out of EF migrations entirely and run it as a background job or an out-of-band script, because migrations should be fast and deterministic. A batched backfill in raw SQL looks like this:

Enjoying this article?

Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.

DECLARE @batch INT = 5000;
WHILE 1 = 1
BEGIN
    UPDATE TOP (@batch) c
    SET c.legal_entity_name = c.customer_name
    FROM customers c
    WHERE c.legal_entity_name IS NULL
      AND c.customer_name IS NOT NULL;

    IF @@ROWCOUNT < @batch BREAK;
    WAITFOR DELAY '00:00:01';
END;

The WAITFOR DELAY is not a nicety. It gives the database room to breathe, lets replication keep up, and keeps lock contention with live traffic to a minimum. Slower is safer, and on a production payments system safer always wins.

Indexes and Constraints Without Locks

Adding an index is one of the most common ways teams accidentally cause an outage, because a default index build holds a lock that blocks writes for the duration. On any table large enough to matter, that duration is long enough to break things. The fix depends on your engine, but the principle is universal: build indexes online or concurrently, never inline with the rest of a migration.

On SQL Server I use WITH (ONLINE = ON) where the edition supports it; on PostgreSQL the equivalent is CREATE INDEX CONCURRENTLY, which cannot run inside a transaction and therefore must be lifted out of EF Core's default transactional migration wrapper. Constraints follow the same logic. Adding a NOT NULL constraint or a foreign key validates every existing row, which can be a lengthy locking scan.

The safest constraint is one the database does not have to prove all at once. Add it as not-validated, then validate it as a separate, low-priority operation against data you have already cleaned.

This staging of constraints matters because it lets you decouple the cheap structural change from the expensive validation scan. You get the integrity guarantee you want without forcing the database to acquire a heavy lock at the worst possible moment.

Coordinating Deployment and Schema

The hardest part of zero-downtime migration is not the SQL. It is the choreography between application deployments and schema state. The rule I enforce is that the schema must always be compatible with both the version of the code currently running and the version about to be deployed. This is sometimes called the N-1 compatibility rule, and it is non-negotiable on my teams.

Concretely, this means you never deploy a schema change and the code that depends on it in the same release. The schema expands first, in its own release, and only once that is fully rolled out and verified does the dependent code follow. The contraction happens in a third release, after you are confident no running instance references the old shape. Rolling deployments and blue-green strategies both work, but only if the database respects this ordering.

I also instrument the rollout. Before I drop a column, I want telemetry proving that nothing has read or written it for a meaningful window. Hope is not a deployment strategy, and in a regulated environment you will be asked to demonstrate that a change was safe, not merely assert that it was.

Rollback and the Point of No Return

I tell every engineer that a migration without a rollback plan is not a plan, it is a gamble. The expand-and-contract pattern is valuable precisely because most of its steps are trivially reversible. An additive nullable column can be dropped. A backfill can be re-run. A dual-write can be turned off with a feature flag. As long as you have not yet contracted, you can retreat.

The contract step is different, and I treat it with appropriate respect. Dropping a column or a table is the point of no return. Before that operation runs, I want a verified backup, a written confirmation that the data is no longer needed, and ideally a soft-delete period where the object is renamed and quarantined rather than destroyed. I have more than once renamed a table to customers_deprecated and left it untouched for a release cycle before truly dropping it.

Feature flags deserve a specific mention here. They let me separate the act of deploying code from the act of activating behavior. I can ship the dual-writing code dark, enable it gradually, watch the metrics, and flip it off instantly if something looks wrong, all without a redeploy. In a downtime-sensitive system that decoupling is worth a great deal.

Testing Migrations Before Production

A migration that has only ever run against an empty developer database has not been tested. The behavior you care about, locking and duration and contention, only appears at scale. I require that significant migrations be rehearsed against a restored copy of production-scale data, ideally in an automated pipeline that times each step and fails the build if anything exceeds a threshold.

Containerized databases make this far easier than it used to be. Spinning up a real engine in a test fixture, applying the full migration chain, and asserting on the resulting schema is now routine in a .NET test suite. I also test the down path, because a rollback that has never been exercised is a rollback you do not actually have. The discipline pays for itself the first time a migration that looked fine in review turns out to take twenty minutes against real volume.

Finally, I review migrations as carefully as I review application code, often more so. A pull request that includes a schema change gets a second reviewer who has done this before, and the migration is read line by line. The database is shared state for the entire platform, and shared state in a payments system earns the strictest scrutiny we can give it.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

Conclusion

Zero-downtime migration is not a clever trick or a single tool. It is a habit of mind that treats every schema change as a sequence of small, reversible, independently deployable steps, ordered so that old and new code can always coexist. Expand and contract, batched backfills, online indexes, N-1 compatibility, and rehearsed rollbacks are the load-bearing pieces. EF Core gives you the versioning and the scaffolding, but the judgment about what is safe under production load remains yours. In regulated fintech that judgment is the job, and the teams that internalize it ship change continuously without ever asking their customers to wait.

Enjoyed this article? Share it with others!

Share:

Get new posts in your inbox

Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.

Comments (10)

Leave a Comment

Comments are moderated and will appear after review.

Aliyu Adamu

July 31, 2026

Refreshing to read this framed for our market rather than lifted from a Silicon Valley playbook. Specifically the "EF Core Migrations as the Baseline" piece — the licence guys have views, and that changes the design constraints in ways the US-centric literature never touches.

Michael Taylor

July 28, 2026

Quick q on "Rollback and the Point of No Return" — how do you handle ordering guarantees when the core banking system drifts out of sync? We're on GKE and the sidecar reconciler feels overkill for our scale.

Nicole Thompson

July 21, 2026

Does the "EF Core Migrations as the Baseline" still hold on a 8-engineer team? We're at the awkward middle and some of these patterns feel like they need a dedicated platform team to run properly.

Aliyu Lawal

July 13, 2026

Solid piece. One nit on "EF Core Migrations as the Baseline": the idempotency-key TTL for flutterwave webhook retries is usually more like 56 hours in practice, not 14.

Justin Wilson

July 12, 2026

Thanks for writing it up. A small nit on "Testing Migrations Before Production": in Envoy you get most of this for free via the native driver.

Maryam Aliyu

July 11, 2026

7 years at Interswitch before moving to a scaleup. We hit this exact thing with NIBSS Verve settlement last May — manual toil was the presenting symptom, and the "Testing Migrations Before Production" section is basically how we untangled it. Ended up pulling out a background worker on Base24, cut p99 latency by 43%. For context: 445k txn/day.

Stephanie Baker

July 7, 2026

Does the "Rollback and the Point of No Return" still hold on a 8-engineer team? We're at the smaller end of that and some of these patterns feel like they need a dedicated ops person to run properly.

Megan Scott

July 5, 2026

Quick question on "EF Core Migrations as the Baseline" — does the pattern hold when you cannot control the client? We keep running into the partner-driven case and the textbook answers do not always survive contact.

Kelechi Ezeh

June 28, 2026

6 years at Interswitch before joining a challenger bank. We hit this exact thing with terminal debug logs last sprint — error rate was the presenting symptom, and the "EF Core Migrations as the Baseline" section is basically how we untangled it. Ended up pulling out a stateless matcher on Thales HSM, cut error rate by 55%. For context: 156k txn/day.

Chris Wilson

June 27, 2026

Good topic. EM for 5 years, was IC for 12 before that here. What we do differently: split the read and write paths at the DB level on Small Improvements. It is not universally better; operational complexity is real, but the recovery story is dramatically better and that pays for itself the first time you have to answer a FCA question at 3am.

About the author

Anselm Fowel

Anselm Fowel

Chief Technology Officer & fintech architect. 16+ years leading engineering across AlliancePay, Mondu, Transalliance, Global Accelerex, and Fidelity Bank — writing here about engineering leadership, fintech architecture, and AI in production.

Read next

Subscribe to the newsletter

Practical notes on engineering leadership, fintech, and building with AI — delivered to your inbox. No spam, unsubscribe anytime.

Anselm Fowel

Chief Technology Officer | Fintech Architect | Engineering Leader

Building the future of financial technology through innovative engineering and strategic leadership.

Expertise

  • CTO Advisory
  • Fintech Architecture
  • Team Leadership
  • Technical Strategy
  • System Design

Get In Touch

[email protected]
Lagos, Nigeria

© 2026 Anselm Fowel. Crafted with passion.