A bulk data migration that blocks production for four hours doesn’t feel like a technical problem. It feels like a business problem. Revenue stops. Customers refresh. Support gets flooded. And the engineer who greenlit it is answering questions for weeks.
The good news: you don’t have to choose between moving data and staying live. The bad news: most teams skip the hard part and pay for it.
This post walks through the patterns that make zero-downtime bulk migrations real. Not theoretical. Not “mostly downtime.” The kind of migration where your application keeps serving requests, your database keeps accepting writes, and at the end of it, your data is in the right place with zero customers noticing.
The Cost of Migration Downtime
Let’s start with what downtime actually costs. A SaaS business with 500 enterprise customers running at $5,000 MRR per customer is doing $2.5M in monthly revenue. That’s roughly $83K per hour. A four-hour migration window isn’t just downtime—it’s $332K sitting on the table.
But the financial math is only half the story. Downtime erodes trust in ways that don’t show up on a P&L. Customers who experience downtime during a critical business moment (end of quarter, product launch, fraud investigation) remember it. They start evaluating alternatives. They add you to the “risky vendor” mental list. That trust is harder to rebuild than the data you migrated.
There’s also the operational load. A scheduled maintenance window means communication, runbooks, escalation paths, monitoring theater, and post-mortems. It means your on-call engineer is awake at 2 AM. It means your product team can’t ship that day. It means your customer success team is fielding angry emails. The hidden cost of downtime—the coordination tax—is often larger than the downtime itself.
The reason most teams accept downtime is simple: zero-downtime migrations are harder to engineer. They require more code, more state tracking, more validation, and more patience. But once you’ve done it three times, it becomes a repeatable pattern. And then downtime becomes optional.
Dual-Write: The Foundation
Every zero-downtime bulk migration starts with dual-write. Your application writes to both the old storage and the new storage simultaneously. This is the bridge that keeps the lights on.
The pattern is straightforward: intercept every write operation (INSERT, UPDATE, DELETE) and fan it out to both systems. If you’re migrating from one PostgreSQL schema to another, your application layer becomes the translator. If you’re migrating from PostgreSQL to a specialized database (Elasticsearch, a data warehouse, a time-series database), dual-write ensures both stay in sync.
Here’s what this looks like in practice. Say you’re migrating customer records from a monolithic PostgreSQL database to a sharded setup. Your application receives a request to update a customer profile. Instead of one INSERT:
INSERT INTO customers (id, name, email) VALUES (123, 'Alice', 'alice@example.com')
You now issue two:
// Old system
OLD_DB.INSERT INTO customers (id, name, email) VALUES (123, 'Alice', 'alice@example.com')
// New system
NEW_DB.INSERT INTO customers (id, name, email) VALUES (123, 'Alice', 'alice@example.com')
But here’s where it gets tricky. What happens if the new write fails? Do you fail the entire request? That would block production. Do you ignore the failure? Then your new system falls out of sync. The answer depends on whether the new system is read-critical or write-critical.
If the new system is not yet serving reads (which it shouldn’t be during the backfill phase), a write failure is non-blocking. Log it, alert on it, but let the request succeed. You’ll fix the discrepancy during validation. If the new system is already serving some reads, you have to be more careful. You might queue the write for retry, or you might fail the request—but then you’re blocking production, which defeats the purpose.
The safest pattern is to keep the new system write-only during the backfill phase. No reads from the new system until all historical data is migrated and validated. This way, a dual-write failure doesn’t affect your application’s behavior. Your users don’t see stale data. Your SLAs don’t break.
One more consideration: transaction semantics. If your old system supports ACID transactions and your new system doesn’t (or has different guarantees), dual-write creates a gap. A transaction might succeed on the old system and fail on the new one, or vice versa. This is unavoidable in a distributed migration. Your mitigation is validation and monitoring, not transaction semantics.
Shadow Reads: Validate Before Cutover
Once dual-write is in place, you have two databases that are supposed to contain the same data. They don’t. Not yet. Bugs in your dual-write logic, race conditions in concurrent updates, or failures in the new system’s write path have created discrepancies.
Shadow reads let you find those discrepancies before they affect customers.
The pattern is: for every read operation, query both the old and new systems, compare the results, and serve from the old system. Log any mismatches. Don’t block the request on the comparison—that would add latency. Run the comparison asynchronously, in the background.
Here’s what this looks like:
async function getCustomer(id) {
// Serve from old system (source of truth during migration)
const oldData = await OLD_DB.query('SELECT * FROM customers WHERE id = ?', [id])
// Shadow read from new system (async, non-blocking)
NEW_DB.query('SELECT * FROM customers WHERE id = ?', [id])
.then(newData => {
if (JSON.stringify(oldData) !== JSON.stringify(newData)) {
logger.error('MIGRATION_MISMATCH', { id, oldData, newData })
metrics.increment('migration.mismatches')
}
})
.catch(err => logger.error('SHADOW_READ_FAILED', { id, err }))
return oldData
}
Shadow reads do three things: they validate your dual-write logic at scale, they let you measure data consistency before cutover, and they give you early warning of systematic problems.
In a migration I ran for a mid-market SaaS, shadow reads found that nested object updates (where a customer record contains an array of linked entities) weren’t being serialized correctly to the new system. The bug was in the application layer, not the database. Dual-write was executing, but the data shape was wrong. We would have discovered this during cutover, when we flipped reads to the new system and users started seeing corrupted data. Instead, we caught it during the shadow read phase, fixed the serialization logic, and re-validated. Downtime: zero. Customer impact: zero.
Shadow reads also tell you when you’re ready to cutover. Once your mismatch rate is zero (or acceptably close, usually <0.01%), you have confidence that the new system is consistent with the old one. You can stop waiting and flip the switch.
The Cutover Moment
After dual-write has been in place for days or weeks, and shadow reads show zero discrepancies, you’re ready to switch reads to the new system. This is the moment everyone is nervous about.
The cutover itself is simple: flip a feature flag that changes your application to read from the new system instead of the old one. But before you do, you need a rollback plan.
Rollback means: if you flip the flag and customer-facing errors spike, you flip it back. Reads go back to the old system. Dual-write continues. You investigate what went wrong, fix it, validate again, and flip the flag again.
A proper rollback takes seconds. It’s not a database operation. It’s a flag toggle. Your application doesn’t need to re-fetch data from the old system. The old system is still being written to by dual-write, so it’s current.
Here’s what the cutover looks like in code:
async function getCustomer(id) {
const useNewSystem = await featureFlags.isEnabled('MIGRATE_CUSTOMERS_TO_NEW_DB')
if (useNewSystem) {
return NEW_DB.query('SELECT * FROM customers WHERE id = ?', [id])
} else {
return OLD_DB.query('SELECT * FROM customers WHERE id = ?', [id])
}
}
The moment you flip that flag, you’re no longer in migration mode. You’re in validation mode. Your monitoring should be dialed in. Error rates, latency, cache hit rates—anything that might indicate the new system is behaving differently. Spend the first hour watching dashboards. If something looks wrong, rollback. If everything looks good, declare victory.
One thing most teams get wrong: they try to optimize the cutover by flipping reads and deleting dual-write in the same operation. Don’t. Dual-write should stay in place for days or weeks after cutover. It’s your safety net. If you find a consistency bug three days later (and you will), you can re-enable shadow reads, validate, and fix without touching production.
Backfill Strategies at Scale
Dual-write handles new data. But you have historical data—millions of rows that were written before dual-write was enabled. That data needs to move to the new system. That’s the backfill.
The backfill is the expensive part, and it’s where most migrations fail. If you’re not careful, your backfill job hammers the database so hard that production queries slow down. Customers notice. SLOs break. You have to kill the backfill job and start over.
The key insight: backfill is not special. It’s just a series of reads from the old system and writes to the new system. Your backfill job is a background worker that competes with production traffic for database resources.
To avoid starving production, you need to throttle the backfill. Read a batch of rows, write them to the new system, wait a bit, repeat. The wait time is the key. If you’re reading 1,000 rows per second and production is hitting 10,000 queries per second, you’re taking 10% of the database’s capacity. That’s acceptable. If you’re reading 50,000 rows per second, you’re competing with production. Queries slow down. Cutomers complain.
Here’s a backfill worker that throttles itself based on database load:
async function backfillWorker() {
const batchSize = 1000
let offset = 0
while (true) {
// Check database load before reading
const dbLoad = await OLD_DB.query('SELECT load FROM pg_stat_statements')
if (dbLoad > LOAD_THRESHOLD) {
// Too much traffic, back off
await sleep(5000)
continue
}
// Read a batch
const rows = await OLD_DB.query(
'SELECT * FROM customers ORDER BY id LIMIT ? OFFSET ?',
[batchSize, offset]
)
if (rows.length === 0) break // Done
// Write to new system
await NEW_DB.insertBatch(rows)
// Update progress
offset += rows.length
logger.info('BACKFILL_PROGRESS', { offset, total: await OLD_DB.count() })
// Wait before next batch
await sleep(1000)
}
}
Another strategy: backfill during off-peak hours. If your application has predictable traffic patterns (lower load at night, weekends), concentrate your backfill during those windows. You can read faster when production isn’t competing.
A third strategy: backfill to a replica. If you have a read replica of your old database, point your backfill job at the replica instead of the primary. This removes backfill load from the primary entirely. Production traffic doesn’t compete with backfill at all. The only caveat: make sure the replica is current. If it’s lagged, your backfill will be lagged too.
Finally, backfill in parallel if your new system can handle it. If you’re migrating to a sharded setup, you can run multiple backfill workers in parallel, each handling a shard. This speeds up the migration without increasing load on the old system (each worker touches a different shard, so they don’t compete).
Rollback Is Not a Safety Net
I’m going to say this plainly: if your rollback plan is “restore from a backup,” you don’t have a rollback plan.
A proper rollback is fast and surgical. It doesn’t require any database operations. It doesn’t require any data recovery. It’s a flag toggle. Your application flips back to reading from the old system, which has been receiving dual-writes the entire time.
But here’s the catch: dual-write is not bidirectional. After you cutover and start reading from the new system, writes still go to both systems. But if you rollback reads to the old system, you have a consistency window where new writes go to the new system, but reads come from the old system. The old system is falling behind.
This is fine if your rollback window is short (minutes). During those minutes, reads are slightly stale. That’s acceptable. But if you’re going to stay on the old system for hours or days while you debug, you need to switch back to dual-write with the old system as the primary. New writes should go to the old system first, and the new system second. This reverses the direction of the migration, but it keeps both systems in sync.
Here’s what this looks like:
async function insertCustomer(data) {
const useNewSystemAsWrite = await featureFlags.isEnabled('MIGRATE_CUSTOMERS_WRITE_PRIMARY')
if (useNewSystemAsWrite) {
// New system is primary
await NEW_DB.insert(data)
await OLD_DB.insert(data).catch(err => logger.error('DUAL_WRITE_FAILED', err))
} else {
// Old system is primary
await OLD_DB.insert(data)
await NEW_DB.insert(data).catch(err => logger.error('DUAL_WRITE_FAILED', err))
}
}
This flipping of the primary is the reason you need to keep dual-write in place for weeks after cutover. If you discover a bug in the new system three days after you flip reads, you can flip back, re-enable the old system as primary, and give yourself time to debug without losing data consistency.
One more point: test your rollback before you need it. Run a rollback drill. Flip the flag, watch the metrics, flip it back. Make sure your team knows what to do when something goes wrong. The worst time to learn your rollback is broken is 3 AM when your error rate is spiking.
Validation After Migration
The migration isn’t done when you flip the flag. It’s done when you’ve validated that the new system is correct.
Validation means running queries against both systems and comparing results. For a customer migration, you might run:
SELECT COUNT(*) FROM customers -- should be the same on both systems
SELECT SUM(lifetime_value) FROM customers -- should be the same
SELECT * FROM customers WHERE id = 12345 -- spot-check a few rows
Run these queries the day after cutover, the week after cutover, and the month after cutover. Use them to build confidence that the migration succeeded. Once you’re confident, you can deprecate the old system and delete the dual-write code.
But here’s the thing: don’t rush to delete the old system. If you discover a consistency bug six months later (and you might), you’ll want the old data for forensics. Keep it around for at least a quarter. Then delete it.
A bulk data migration that ships without downtime is the kind of thing that goes unnoticed. Customers don’t see it. Support doesn’t get tickets. The engineering team ships it on a Tuesday and moves on to the next problem. That invisibility is the whole point. You’ve taken a risky operation and made it safe. You’ve taken something that used to require a maintenance window and made it something you can do during business hours. That’s the win.
If you’re facing a bulk migration that feels impossible without downtime, the pattern is proven. Dual-write, shadow reads, throttled backfill, feature-flag cutover, and a strong rollback plan. The engineering is more complex than a simple backup-and-restore, but the payoff—zero downtime, zero customer impact, zero lost revenue—is worth it. If you’re evaluating whether to tackle this in-house or bring in help, the application takes ten minutes, and these are exactly the kinds of problems where a senior engineer’s judgment makes the difference between a smooth migration and a 4 AM incident.





