Dual-Writing Across Two Databases During a Cutover
You are moving a table from an old database to a new one — a different engine, a different cluster, or a fresh account you cannot reach in a single transaction — and for the cutover window your application has to write both. Reads still come from the old database, the new one is being backfilled and verified, and every live mutation must land in both so the two never drift while you watch. Then the pager goes off: the new database timed out on a write that the old one already committed, and now one store is a row ahead of the other with no outage and no stack trace to point at the moment it happened. This page is the runbook for that cross-database window — how to order the two writes, what to do when one commits and the other fails, and how to prove the two datastores agree before you move reads over.
Two databases cannot share a local transaction, so the atomicity you rely on inside one engine is gone the instant the second store is a separate system. This is the hardest surface of Dual-Write Synchronization, and it is the write half of the Expand and Contract Methodology: you cannot switch reads or run the contract step until both stores are provably consistent. Examples are PostgreSQL on both ends; the MySQL 8.0 INSERT … ON DUPLICATE KEY UPDATE form is equivalent where noted.
Symptom / Error Signatures
Cross-database dual-write failures rarely raise a clean error at the point of corruption. The write that fails is the second one, after the first has already committed, so the application often logs a warning and moves on. Watch for these signatures instead:
- A windowed checksum job reports a nonzero delta between the old and new databases:
divergent rows: 412 (0.07%). - Application logs show
context deadline exceeded,driver: bad connection, orERROR: canceling statement due to statement timeouton the new datastore only, with the same request having succeeded on the old one. pg_stat_activityon the old database shows sessionsidle in transaction— a request that committed the old write, then blocked waiting on the slow new store while holding nothing to roll back.- Row counts diverge in one direction only: the old database is consistently ahead, which is the fingerprint of an old-first, non-durable ordering.
- Foreign-key or
NOT NULLviolations during reconciliation because a parent row reached one database but its child reached the other.
Confirm the drift with a windowed comparison keyed on the primary key and a monotonic timestamp, never a full-table scan across both engines:
-- PostgreSQL (old database) · read-only · safe on primary · batches via LIMIT on indexed id
-- Context: run against the OLD store, then diff the id/updated_at set against the new store.
SELECT id, updated_at
FROM orders
WHERE updated_at >= now() - INTERVAL '2 hours'
ORDER BY updated_at DESC
LIMIT 5000;
Root Cause Analysis
Every silent cross-database loss traces to one fact: two independent commits cannot be made atomic by sequencing them. Whatever order you pick, there is a gap between the first commit and the second where a crash, a timeout, or a deploy leaves one database ahead. The order you choose only decides which database ends up ahead and whether the gap is recoverable. Three ordering strategies diverge sharply here:
| Ordering strategy | Failure mode if the second write fails | Recoverable without a scan? | Use when |
|---|---|---|---|
| Old-first, best-effort new write | Old committed, new lost silently; old is ahead | No — only reconciliation finds it | Never for a cutover |
| New-first, best-effort old write | New committed, old lost; reads (still on old) miss the row | No — user-visible immediately | Never |
| Old-first + transactional outbox | Old and outbox commit together; relay retries the new write | Yes — the outbox is the durable record | Always, for cross-database |
The first two are best-effort dual calls and both lose data; they differ only in which store you notice first. The durable option makes the intent to write the second database part of the first database’s transaction: the old row and an outbox row commit atomically, and a relay drains the outbox to the new store with at-least-once, idempotent delivery. That is the same mechanism proven in Preventing Data Loss During Dual-Write Migrations, applied across a database boundary instead of two tables in one engine. Because delivery is at-least-once, every write into the new database must be idempotent and version-guarded, or a retry double-applies and a reordered message clobbers a newer value.
Immediate Mitigation
If your checksum job is reporting active divergence mid-cutover, contain it before it compounds. Reads are still on the old database, so the old store is authoritative — that is your safety net. Execute in this exact order.
-
Freeze the read cutover. If a feature flag is ramping reads toward the new database, set it back to the old store immediately so no user reads an inconsistent new row.
# bash · incident response · run from a host with flag-service credentials # Context: forces reads back to the authoritative OLD database; instant, no data touched. ./bin/flags set read_source=old_db --tenant all -
Keep dual-writing, but make new-store failures loud. Do not disable the second write — that widens the gap. Instead flip the relay to fail-fast so a slow new database drops to the retry queue instead of holding old-database connections
idle in transaction.# bash · run on the relay/worker hosts · takes effect on next poll # Context: cap the new-store write timeout so a stall enqueues a retry instead of blocking. ./bin/outbox config set new_db.write_timeout=2s new_db.on_timeout=requeue -
Quantify the divergence. Run the windowed comparison from the symptom section in batches to size the affected set before repairing anything. Record the oldest divergent
updated_at— that is when the gap opened. -
Check replication and outbox backlog. A growing unprocessed outbox count is the leading indicator that the new store is falling behind live writes.
-- PostgreSQL (old database) · read-only · run continuously during the incident -- Context: rising undelivered count means the relay cannot keep up — page before it becomes drift. SELECT count(*) AS undelivered, now() - min(created_at) AS oldest_pending FROM outbox WHERE delivered_at IS NULL; -
Repair the new store from the authoritative old store. Re-drive the divergent keys through the version-guarded upsert (below) so the new database is overwritten with old-database truth for exactly those rows. Re-verify counts before lifting the freeze and resuming the read ramp.
Permanent Fix / Long-Term Pattern
The durable pattern is old-first through a transactional outbox, with an idempotent, version-guarded write into the new database, and a read cutover gated on a consistency SLO — never on a calendar date. The mechanism and its data-loss reasoning live in the parent topic, Dual-Write Synchronization; this is how it is shaped for a genuine two-database cutover.
-
Write old-first, into one local transaction with an outbox. The old row and the outbox entry commit together, so the intent to write the new database survives any crash. A relay reads the outbox and applies the new-database write with retries.
-- PostgreSQL (old database) · application request path · single local transaction -- Context: legacy row + outbox row commit atomically; new-DB delivery is async, at-least-once. BEGIN; INSERT INTO orders (id, customer_id, total, updated_at) VALUES (:id, :customer_id, :total, :updated_at); INSERT INTO outbox (idempotency_key, aggregate_id, payload, created_at) VALUES (:idem_key, :id, :payload, now()); COMMIT; -
Make the new-database write idempotent and monotonic. The relay delivers at-least-once, so the write must upsert by a stable key and reject any value older than what is already there. This single predicate makes retries and reordering harmless.
-- PostgreSQL (new database) · executed by the outbox relay · idempotent + reorder-safe -- Context: the conflict WHERE keeps the higher version; a stale re-delivery changes nothing. INSERT INTO orders (id, customer_id, total, version) VALUES (:id, :customer_id, :total, :version) ON CONFLICT (id) DO UPDATE SET customer_id = EXCLUDED.customer_id, total = EXCLUDED.total, version = EXCLUDED.version WHERE EXCLUDED.version > orders.version; -
Keep the old database authoritative until the SLO holds. Advance the read ramp only after a continuous checksum job reports 99.99% consistency over a rolling 24-hour window. The repair-mode tooling that closes the last divergent rows is detailed in Reconciling Divergence Between Dual-Written Tables; backfilling the pre-cutover rows is covered by Backfill Optimization, and gating the whole ramp behind Feature Flag Rollouts is what lets you disable the second store instantly.
-
Cut over reads in stages, then stop dual-writing last. Ramp reads 1% → 50% → 100% while dual-write stays on, hold at full for the SLO window, and only then disable the old-database write and drain the outbox to empty. Removing the old write path before reads have fully moved is how a synchronization bug becomes a user-visible outage.
Verification Checklist
Return to the parent topic for the full mechanism and decision criteria: Dual-Write Synchronization.
Frequently Asked Questions
Which database should I write to first across the boundary? Write the store that reads still come from — the old database — first, and do it in one local transaction with an outbox row. That keeps the authoritative store correct even if the new-database write fails, and the durable outbox means the intent to write the new store is never lost. New-first ordering fails a live read the moment the old write drops, so it is never right during a cutover.
Can I use two-phase commit instead of an outbox for two databases?
You can, but the outbox is almost always better for a cutover. Distributed 2PC blocks all participants when the coordinator fails, turning a transient new-database stall into a stuck old-database transaction holding idle in transaction. The outbox degrades gracefully — the new write simply retries from a durable record — which is exactly the failure handling a cutover window needs.
How do I know it is safe to move reads to the new database? Gate the read ramp on a consistency SLO, not a date. Require a continuous checksum job to report 99.99% agreement over a rolling 24-hour window, an outbox backlog that drains to zero under peak load, and a rehearsed rollback. Ramp reads in stages with dual-write still on, and stop dual-writing only after reads are fully cut over and the SLO has held.