· 4 min read

PostgreSQL Migrations Without Downtime: An Idempotent ETL Playbook


The Problem

You’ve decided to migrate from Sheets (or AppSheet, or Excel) to PostgreSQL. The business can’t stop while you migrate — orders are still coming in, customers are still being created, and someone is still editing the old sheet.

You need a migration that is:

  • Zero-downtime. The old system stays live during the migration.
  • Idempotent. You can re-run it safely — it won’t duplicate rows.
  • Verifiable. You can prove row counts and aggregates match between old and new.
  • Reversible. If something goes wrong on cutover day, you can switch back in minutes.

Here’s the pattern I’ve used on multiple migrations. It works whether your source is Google Sheets, AppSheet, a CSV export, or another API.


The Pattern: Idempotent Cursor + Parallel Run

Step 1: Read with a watermark

Never do a full extract on every run. Use an updatedAt timestamp as your cursor:

-- On the first run, create a migration_state table
CREATE TABLE migration_state (
  source     TEXT PRIMARY KEY,
  last_cursor TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01'
);

-- Each sync run: fetch rows updated since the cursor
SELECT * FROM source_customers
WHERE updated_at > (SELECT last_cursor FROM migration_state WHERE source = 'sheets')
ORDER BY updated_at ASC;

Step 2: Upsert, never INSERT

Use ON CONFLICT with a stable business key — not an auto-increment ID from the source, which won’t survive a migration:

INSERT INTO customers (external_id, name, email, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (external_id) DO UPDATE SET
  name      = EXCLUDED.name,
  email     = EXCLUDED.email,
  updated_at = EXCLUDED.updated_at;

The external_id is your bridge — it ties the Postgres row back to the source row. Keep it forever, at least in an indexed column.

Step 3: Advance the cursor only after commit

// Pseudocode for a single sync batch
const rows = await fetchSince(lastCursor, BATCH_SIZE);
for (const row of rows) {
  await db.upsert('customers', row);
}
// Only update the cursor AFTER the batch succeeds
await db.query(
  `UPDATE migration_state SET last_cursor = $1 WHERE source = 'sheets'`,
  [rows.at(-1).updatedAt]
);

If the script crashes mid-batch, the cursor stays at the last committed position. Re-run it — idempotent.

Step 4: Validate every night

During the parallel run (typically 1–2 weeks), run a validation script:

-- Row count parity
SELECT 'customers' AS entity, COUNT(*) FROM customers
UNION ALL
SELECT 'sheets_customers', COUNT(*) FROM sheets_import.customers;

Then spot-check aggregates — total revenue by month, orders per customer, anything the business cares about. If they match for 5 consecutive nights, you’re ready to cut over.


The Cutover Checklist

When both systems have matched for at least 5 days:

  • Freeze writes to the old system. Announce a 2-hour window. If it’s Sheets, protect the range.
  • Run one final sync. Catch the last few rows created since the last nightly validation.
  • Run the validation one more time. If clean, proceed.
  • Switch DNS / config. Point the app at the new database. Keep the old connection string in an env var.
  • Smoke-test. Log in, create a record, check it appears. Check a few existing ones.
  • Keep the old system read-only for 72 hours. If a panic-rollback is needed, you’re switching DNS back — not restoring a backup.

When This Pattern Breaks (and what to do)

ScenarioFix
Source has no updatedAt columnAdd one. If you can’t, use a hash of all columns as a change detector — slower, but works.
Source supports hard deletesYou need a full-diff approach: compare IDs in source vs target and soft-delete missing ones. Add a deleted_at column to Postgres instead of hard-deleting.
Schema changes during migrationFreeze the source schema for the duration of the parallel run. If it must change, restart the cursor from zero after the change lands in Postgres.
Millions of rows, no batch paginationUse keyset pagination: WHERE updated_at > $cursor ORDER BY updated_at, id LIMIT 1000. Never use OFFSET on large tables.

Real Examples

This exact pattern powered the migration in two case studies:


TL;DR

PatternIdempotent ETL: cursor → upsert → advance cursor → validate nightly
Key techniqueON CONFLICT (external_id) DO UPDATE with a stable business key, never auto-increment
Parallel run1–2 weeks; run validation every night; cut over when aggregates match for 5+ days
RollbackKeep old system read-only for 72h; switch DNS back — don’t restore a backup
Edge casesNo updatedAt → hash diff. Hard deletes → full diff. Schema changes → freeze or restart.