Postgres Snapshot Export for Consistency

PostgreSQLBackendDatabase
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

You need a point-in-time slice of Postgres that every table agrees on—the parent row exists when the child foreign key references it, the ledger balance matches the sum of line items, the search index bootstrap matches the relational source. pg_dump solves this for small databases by holding one transaction open for hours. At scale that blocks vacuum, pins WAL, and turns a backup job into an incident waiting for a traffic spike.

Postgres snapshot export is the mechanism that separates declaring a consistent read view from consuming it. One coordinator calls pg_export_snapshot(), hands the identifier to parallel workers, and each worker runs SET TRANSACTION SNAPSHOT '…' before streaming COPY. Writers keep committing; readers share one frozen MVCC picture until the last exporter commits or rolls back.

MVCC in one paragraph (because exports depend on it)

Every row version carries xmin (inserting transaction) and optionally xmax (deleting/updating transaction). A snapshot records which transactions were committed, in-progress, or aborted at snapshot creation time. SELECT returns tuples visible to that snapshot. Two sessions with different snapshots can read different values for the same primary key simultaneously—that is normal, not a bug.

Export consistency means: all tables in the export job use the same snapshot, so cross-table joins you perform offline reproduce what would have been true at snapshot time. It does not mean the database stops changing; it means your files represent one coherent cut.

Coordinator pattern with pg_export_snapshot

The canonical flow:

-- Coordinator (session 1)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT pg_export_snapshot();
-- Returns something like: 00000008-00000008-1
-- Pass this string to workers via queue, env var, or Redis with TTL

Workers:

-- Worker (session N)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION SNAPSHOT '00000008-00000008-1';
COPY orders TO '/exports/orders.csv' WITH (FORMAT csv, HEADER true);
-- Repeat per table; COMMIT when done

Rules that save production:

  1. Isolation must be REPEATABLE READ or SERIALIZABLE. Read Committed cannot import a snapshot.
  2. Export parents before children if downstream loaders enforce FK order—or disable FK checks during load with explicit acceptance of risk.
  3. Never mix snapshots across workers; a typo creates silent inconsistency.
  4. Set lock_timeout and statement_timeout on workers so a stuck COPY fails fast instead of holding xmin for days.

For programmatic export, the Go database/sql pattern is: open coordinator connection, BeginTx with sql.LevelRepeatableRead, QueryRow("SELECT pg_export_snapshot()"), fan out snapshot string, each worker Exec("SET TRANSACTION SNAPSHOT $1", snap) inside its own RR transaction.

Parallel logical dump without pg_dump monolith

Managed services often ban shelling out to pg_dump. Custom exporters become necessary. Snapshot export is how you keep parity with pg_dump --snapshot semantics while sharding table COPY across pods.

Architecture sketch:

Coordinator                    Workers (N)
     |                              |
 BEGIN RR                         BEGIN RR
 pg_export_snapshot() ------> SET TRANSACTION SNAPSHOT
 publish to Redis (TTL)           COPY large_table
     |                              |
 wait on barrier                  hash file (SHA-256)
     |                              |
 all done? COMMIT                   COMMIT
 write manifest.json

The manifest lists table name, row count, snapshot id, file hash, and export timestamp. Loaders verify hashes before promoting data. Partial success must not write a "complete" marker—object storage versioning makes that easy.

Table ordering: topological sort on foreign keys. Cycles (rare) need staging tables or deferred constraints during load.

Wide tables: split by primary key ranges inside the same snapshot—all range workers share the snapshot string, each runs WHERE id BETWEEN … COPY. Row counts in manifest segments must sum to coordinator count query run under the same snapshot.

CDC initial load and the snapshot handoff

Logical replication and Debezium stream changes from a replication slot; they do not magically include historical rows already compacted out of WAL. Initial load requires a consistent baseline plus a start LSN so the stream continues without gaps or duplicates.

Two common patterns:

Pattern A — Debezium snapshot mode: The connector runs its own consistent snapshot (often via REPEATABLE READ or export) then switches to the slot. You inherit Debezium's ordering guarantees but less control over parallelism.

Pattern B — Custom baseline + slot coordinate: Pause or create slot, take snapshot export of base tables, load warehouse, record pg_current_wal_lsn() from coordinator at snapshot time (or use slot's confirmed_flush_lsn after controlled start), then start consumer from that LSN. Harder, but you control COPY parallelism and file layout.

The failure mode to design for: baseline exported at snapshot S while slot begins at LSN L. If changes between S and L are not captured, you lose data. If you replay overlapping ranges, you duplicate. Document whether consumers upsert by primary key + LSN or use idempotent merge.

Never run COPY from foreign tables inside the snapshot transaction expecting remote consistency—FDW reads are not pinned to the local snapshot.

xmin horizon, vacuum, and operational limits

Open export transactions advance the cluster's oldest xmin. Effects:

Symptom Cause Mitigation
Table bloat climbs Dead tuples not removable Shorter export window; more workers
transaction_id_wraparound warnings Very old xmin Kill runaway sessions; cap export duration
Replication slot lag unrelated Separate issue—but compounding disk pressure Monitor both
idle in transaction coordinator Forgotten session Alert on age > threshold

Query the horizon:

SELECT age(datfrozenxid) AS db_age,
       (SELECT min(backend_xmin::text::bigint)
        FROM pg_stat_activity
        WHERE backend_xmin IS NOT NULL) AS oldest_active_xmin
FROM pg_database
WHERE datname = current_database();

Set alerts when export jobs exceed expected duration—P99 of historical runs plus margin.

Comparison with other consistency tools

pg_dump / pg_dumpall: Single process, built-in ordering, --snapshot for parallel directory format. Best default for moderate size; harder to customize throughput.

Filesystem snapshots (EBS, ZFS): Crash-consistent at block level unless Postgres is stopped or uses pg_start_backup/pg_stop_backup for physical backup. Not a substitute for logical cross-table consistency unless you recover to a running instance and then logical export.

Repeatable read ad hoc: Each analyst opens RR and queries—fine for reports, not for coordinated multi-table file export without pg_export_snapshot.

Testing exports under write load

Before trusting a pipeline:

  1. Hammer writes during export (pgbench or app load test).
  2. Verify FK closure offline: load staging DB, run ALTER TABLE … VALIDATE CONSTRAINT.
  3. Compare row counts to SELECT count(*) on coordinator at snapshot time (stored in manifest).
  4. Chaos: kill one worker mid-COPY; ensure job retries from scratch with new snapshot, never publishes partial manifest.

Property-based tests on merge logic catch off-by-one snapshot reuse bugs that integration tests miss.

Security and compliance notes

Export files contain full table data at rest—encrypt object storage, restrict IAM, rotate keys. Snapshot identifiers are not secrets but export buckets are as sensitive as the database itself.

Audit log: who triggered export, snapshot id, table list, row counts. For GDPR deletes mid-export, snapshot includes rows visible at cut time even if deleted milliseconds later on primary.

When not to use snapshot export

Snapshot export shines when you need parallel IO, custom formats, or CDC baseline alignment while keeping MVCC guarantees Postgres already provides—without reinventing consistency in application code that breaks the first time a retry duplicates half a table.

Resources

Frequently asked questions

What is a Postgres snapshot identifier and why does it matter for exports?

A snapshot identifier (like 00000008-00000008-1) pins a single MVCC read view across one or many connections. Every session that imports that snapshot with SET TRANSACTION SNAPSHOT sees identical row versions at the same logical point in time, even while writers continue committing on the primary.

How is pg_export_snapshot different from pg_dump --snapshot?

Both rely on the same MVCC mechanism. pg_dump --snapshot takes a snapshot internally and holds it for the whole dump. pg_export_snapshot lets you publish the snapshot string to other connections—workers, COPY jobs, or custom exporters—so parallel export pipelines share one consistent cut without each opening its own long transaction.

Can snapshot exports block autovacuum or cause table bloat?

Yes. Any open transaction—including one holding an export snapshot—prevents vacuum from removing dead tuples those transactions might still need to see. Long exports increase xmin horizon age, raise bloat, and can trigger wraparound warnings. Keep export windows short, parallelize COPY work, and monitor pg_stat_activity and oldest xmin.

Does snapshot consistency apply to foreign tables or logical decoding?

No for foreign data wrappers: remote rows are read at query time with no shared MVCC state. Logical decoding slots advance independently of REPEATABLE READ snapshots. Treat FDW and CDC as separate consistency domains; combine local snapshot export with slot-coordinated CDC only after explicit design.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →