Estuary

PostgreSQL to Snowflake: CDC, Replication & Data Ingestion Guide

Compare PostgreSQL to Snowflake methods, including logical-replication CDC, Openflow, Debezium, and Estuary, with setup steps, cost control, and slot recovery tips.

PostgreSQL to Snowflake integration guide cover showing the PostgreSQL and Snowflake logos
Share this article
null success story logo
David Energy

David Energy Prioritizes Reliability with Estuary.

Read success story

PostgreSQL data can be moved to Snowflake with a one-time export and COPY INTO, scheduled incremental queries, or log-based change data capture (CDC). For a production database that changes continuously, CDC is usually the better fit: it reads inserts, updates and deletes from the write-ahead log (WAL) through a logical replication slot instead of re-querying tables, and unlike incremental queries it sees hard deletes.

The main constraints sit at the two ends: PostgreSQL keeps WAL behind a stalled slot and loses slots on failover or upgrade, and Snowflake bills warehouse time for every MERGE. Estuary is one managed option for this route: it captures PostgreSQL changes through logical replication into a durable collection and loads Snowflake on a configurable schedule.

Choosing a PostgreSQL to Snowflake method

ApproachBest fitFreshnessOperational burdenExample implementation
One-time exportSingle migration or snapshotPoint in timeLow, but manualCOPY ... TO CSV → stage → COPY INTO
Scheduled incremental batchSmall tables, hourly or daily freshnessAt the scheduleMedium: orchestrator and MERGE SQLAirflow or cron with updated_at > :last queries
Snowflake-native CDCSnowflake is the only destinationOngoingMedium: Openflow runtimeOpenflow Connector for PostgreSQL
Self-managed streaming CDCYou already run KafkaOngoing, as appended rowsHigh: Kafka, Debezium, your own dedup/MERGEDebezium + Kafka + Snowflake Kafka connector
Managed CDC serviceProduction database, no pipeline codeContinuous capture, scheduled loadsLow: configurationEstuary

Batch methods miss hard deletes and re-scan PostgreSQL every run, so most of this guide assumes CDC.

How PostgreSQL to Snowflake CDC works

CDC decodes committed changes from the WAL and applies them to Snowflake in batches.

  • wal_level = logical makes the WAL detailed enough to decode row changes.
  • A publication lists the tables that emit change events.
  • A replication slot is the consumer's cursor. PostgreSQL retains every WAL segment the slot still needs, even with no consumer connected.
  • pgoutput, the built-in output plugin, emits inserts, updates and deletes in commit order.

A new pipeline backfills existing rows, then streams from the slot, reconciling the two so a row changed mid-backfill is neither lost nor applied out of order.

Snowflake is loaded one of two ways. Stage and MERGE merges changes by primary key into a current-state table, on a warehouse. Append (Snowpipe Streaming, the Kafka connector) inserts each change as a row without reading the table, producing a change log.

PostgreSQL backfill and replication slot changes merged or appended in Snowflake.
The slot retains every WAL segment it still needs, even with no consumer connected.

PostgreSQL requirements

Enabling logical replication depends on the host:

Where PostgreSQL runsEnable logical replicationWatch out for
Self-hostedALTER SYSTEM SET wal_level = logical; + restartRestart required
Amazon RDS / Aurorards.logical_replication = 1 in the (cluster, for Aurora) parameter group, rebootGrant rds_replication
Google Cloud SQLFlag cloudsql.logical_decoding = onUser WITH REPLICATION IN ROLE cloudsqlsuperuser
Azure Database for PostgreSQL (flexible server)Server parameter wal_level = logical, restartmax_slot_wal_keep_size is read-only at -1; low storage drops unused slots
SupabaseUse the direct connectionThe pooler doesn't carry replication; may need dedicated IPv4
NeonEnable logical replication in project settingsIrreversible, restarts computes; -pooler hosts fail

A least-privilege setup on PostgreSQL 14+:

sql
CREATE USER cdc_user WITH PASSWORD '...' REPLICATION; GRANT pg_read_all_data TO cdc_user; -- older versions: GRANT SELECT per schema CREATE PUBLICATION cdc_publication; ALTER PUBLICATION cdc_publication SET (publish_via_partition_root = true); ALTER PUBLICATION cdc_publication ADD TABLE public.orders, public.customers; ALTER SYSTEM SET max_slot_wal_keep_size = '50GB'; -- PG13+; size for your write rate

Three rules prevent most incidents:

  1. List tables explicitly. A FOR ALL TABLES publication makes PostgreSQL reject updates and deletes on any table without a primary key or replica identity.
  2. Give replicated tables a primary key. Snowflake MERGE matches on it.
  3. Set max_slot_wal_keep_size. The default, -1, lets a stalled slot fill the disk.

Snowflake requirements

Snowflake needs a database, schema, virtual warehouse, and a dedicated user and role with grants on them, authenticated with a key pair (JWT) rather than a password.

  • Set the warehouse to AUTO_SUSPEND = 60. Snowflake bills per second with a 60-second minimum per resume.
  • Choose the timestamp type deliberately (TIMESTAMP_LTZ, TIMESTAMP_NTZ or TIMESTAMP_TZ). If the account sets TIMESTAMP_TYPE_MAPPING, it must match what the pipeline writes.
  • Don't enable QUOTED_IDENTIFIERS_IGNORE_CASE for the pipeline user. Quoted identifiers, including reserved-word columns, must be referenced exactly.

Setting up PostgreSQL to Snowflake replication

  1. Enable logical replication for your host and confirm SHOW wal_level; returns logical.
  2. Create the role and publication, set max_slot_wal_keep_size, and open network access: allowlisted IPs, an SSH tunnel through a bastion, or private networking.
  3. Prepare Snowflake as described above.
  4. Choose the method, and per table choose MERGE (current state) or append (event tables).
  5. Load existing data. For a large database, prioritise tables, skip history you don't need, and watch WAL retention: a tool that snapshots before consuming the slot accumulates WAL for the whole snapshot.
  6. Start ongoing replication and set the Snowflake load interval to the freshness people actually use.
  7. Validate. Compare row counts. Update a row, wait one load interval, and query that key in Snowflake; repeat for a delete and a new column. Stop the pipeline briefly and confirm it resumes cleanly.

What happens after the initial load?

  • Updates merge into the existing row by primary key, or append as a new version in append mode.
  • Deletes are soft by default in most tools (a metadata column marks the row). Hard deletes remove it.
  • New tables emit changes only once they are in the publication.
  • New columns are added automatically by most managed tools. Earlier rows show NULL until re-loaded.
  • Type changes that widen, such as dropping NOT NULL, pass through. Narrowing changes usually force a table rebuild.
  • Dropped columns stay in Snowflake and stop receiving values.
  • TOASTed values. If an update doesn't touch a large out-of-line (TOAST) value, PostgreSQL omits it from the WAL. A merging loader keeps the last known value; an append loader writes the row without it, unless the table uses REPLICA IDENTITY FULL, which costs WAL volume.

PostgreSQL to Snowflake data type mapping

PostgreSQLSnowflake targetNotes
smallint, integer, bigintINTEGERLossless
numeric(p,s), p ≤ 38NUMBER(p,s)Many loaders default to FLOAT
unconstrained numericNUMBER(38,s) or VARCHARPostgreSQL allows far more than 38 digits
real, double precisionFLOATInexact on both sides
text, varchar, uuid, inet, intervalVARCHAR
json, jsonb, arraysVARIANTQuery with payload:customer.id::string; multidimensional arrays may be flattened; check the loader's maximum value size
byteaBINARY or base64 VARCHARCheck your loader
timestamptzTIMESTAMP_TZ / TIMESTAMP_LTZKeeps the instant
timestampTIMESTAMP_NTZDecide whether values are UTC

NUMERIC precision

NUMERIC values lose precision when the pipeline loads them as Snowflake FLOAT (a double, about 15 significant digits) instead of NUMBER(p,s). Money amounts, high-scale ratios and long identifiers get rounded. Map each column explicitly: NUMBER(p,s) where p ≤ 38, and VARCHAR for values that don't fit. Then re-load the table; values already rounded in Snowflake can't be recovered there.

Performance, latency and Snowflake cost

Capture can be continuous, but Snowflake freshness is set by the load interval. Cost comes mainly from how often the warehouse resumes to merge and how much of the table each MERGE scans.

Reducing MERGE cost

  • Lengthen the load interval. Each load resumes the warehouse for at least 60 billed seconds, so a one-minute interval keeps it running permanently.
  • Coalesce by key. A row updated 500 times between loads should be merged once.
  • Fix pruning. Snowflake prunes micro-partitions by per-column min/max. Keys that follow insert order touch few partitions; a random UUID scans most of the table, and a composite key like (tenant_id, id) does the same when each batch spans many tenants. Put a write-order column first, or cluster on the merge columns (clustering bills its own credits). Match key types exactly, because implicit casts stop pruning.
  • Start at X-Small. Merges are mostly I/O.

Snowpipe Streaming vs staged MERGE

Use Snowpipe Streaming for insert-only or event-shaped tables where low load latency matters, and staged MERGE for tables that are updated and deleted. Snowpipe Streaming only inserts: an update arrives as another row for the key, and a delete as a row with a delete marker. Current state then needs a view or dynamic table that keeps the latest row per key, which costs compute too, so for update-heavy tables a merge every 15–30 minutes is often cheaper.

Production considerations

  • Retries and recovery. A restarted pipeline resumes from the slot's confirmed position. MERGE tables absorb a replayed batch; append tables duplicate it unless the tool commits its read position and the Snowflake write together.
  • Source impact. The slot pins WAL and catalog rows until confirmed (see troubleshooting). Backfills add table reads; batch methods re-query every run.
  • Schema changes and deletes. Decide per table: soft or hard deletes, and what happens on an incompatible type change (see the initial-load section).
  • Backfills. Re-backfills that merge into existing tables keep them queryable; truncate-and-reload empties them until the load finishes.
  • Monitoring. Alert on retained WAL and wal_status in pg_replication_slots, not only on Snowflake row counts, and on time since the last successful Snowflake load.
  • Security. Least-privilege PostgreSQL role, key-pair auth on Snowflake, and private connectivity where available.

Common problems and troubleshooting

WAL growth behind an inactive replication slot

WAL fills the disk when a slot stops advancing, because PostgreSQL keeps every segment after the slot's restart_lsn. Find the slot:

sql
SELECT slot_name, active, wal_status, safe_wal_size, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots;
  • Pipeline stopped or deleted: restart it, or drop an abandoned slot with pg_drop_replication_slot('slot_name').
  • Long-running transaction:restart_lsn can't pass the oldest open transaction. Find it in pg_stat_activity.
  • Idle published tables on a busy server: the slot only confirms progress when a published table changes. Add a heartbeat table updated every few minutes.
  • Prevention: set max_slot_wal_keep_size, so PostgreSQL invalidates the slot instead of filling the disk. Size it for worst-case open-transaction time at peak write rate, and alert when wal_status becomes extended or unreserved.

Replication slot lost after failover, upgrade or WAL limit

Without its slot, a pipeline fails with replication slot "..." does not exist and must re-read tables from PostgreSQL. Snowflake stays correct up to the failure. Slots are lost through:

  • Failover. The new primary has no copy of the slot unless it was a synchronized failover slot (PostgreSQL 17+; RDS for PostgreSQL supports it from 17).
  • Major-version upgrade. RDS blocks upgrades while logical slots exist on sources before 17. From 17, pg_upgrade can carry fully consumed logical slots over.
  • max_slot_wal_keep_size exceeded (wal_status = lost), for example when WAL ages out during an outage.
  • Manual cleanup.

Recovery, cheapest first:

  1. Planned, writes paused: confirm the pipeline has caught up, run the upgrade or failover, create a new slot, and resume from the current position without re-reading tables. On 17+ sources, check whether your platform keeps slots (RDS does for non-replica sources). Treat blue/green upgrades as failovers.
  2. Outage start known: re-read only rows changed since then, by timestamp or xmin transaction ID. Rows deleted during the outage are missed.
  3. Otherwise: re-read every table and merge into Snowflake without truncating.

CDC from Aurora, Global Database or a read replica

Capture from the writer. Aurora PostgreSQL doesn't support logical decoding on readers, although community PostgreSQL 16 added it. Where standby decoding works (PostgreSQL 16+), set hot_standby_feedback = on, or the primary can vacuum catalog rows the slot needs and invalidate it. For Aurora Global Database, capture from the primary Region's writer. AWS doesn't document whether logical slots survive a writer failover within a cluster or a cross-Region switchover, so check pg_replication_slots after any failover and be ready to re-backfill.

Using Estuary for PostgreSQL to Snowflake

Estuary pairs the PostgreSQL CDC capture with the Snowflake materialization. The capture manages the slot (flow_slot by default) and backfill, reads the publication you created, and writes each table to a collection (below). The materialization stages each transaction in an internal Snowflake stage and applies it with MERGE, or with COPY INTO when all its keys are new. Against the concepts above:

  • Load interval: the sync schedule defaults to 30 minutes when caught up; fast-sync windows apply your interval in business hours and 4-hour syncs outside them, and backfills ignore it. Changes to the same key are combined between syncs, which is the coalescing that cuts MERGE cost.
  • Merge vs append: merge is the default. Delta updates are per binding and load through Snowpipe Streaming when auth is key-pair (JWT), still on the sync schedule; set Sync Frequency to 0s for low latency.
  • Deletes: soft (_meta/op = 'd') unless hardDelete: true.
  • TOAST: merge bindings keep unchanged TOASTed values; delta bindings don't.
  • Types:NUMERIC becomes FLOAT by default. To keep exact values, set castToString: true on the field, which passes the original decimal string through unchanged. To query it as a number, cast it to NUMBER(38,10) in a view. A DDL override on its own changes only the column type: values are still converted through FLOAT first, so it doesn't fix precision. jsonb and arrays become VARIANT (multidimensional arrays flattened), bytea becomes BINARY, infinity timestamps become 9999-12-31T23:59:59Z, and values over 8 MiB are truncated.

Implementing PostgreSQL to Snowflake with Estuary

These steps adapt Estuary's general data flow guide to this route.

  1. Complete the prerequisites in PostgreSQL requirements and Snowflake requirements. Estuary also needs:
    • A public.flow_watermarks table in cdc_publication that cdc_user can write to; run this as the publication's owner (setup SQL):
sql
CREATE TABLE public.flow_watermarks (slot TEXT PRIMARY KEY, watermark TEXT); GRANT ALL PRIVILEGES ON TABLE public.flow_watermarks TO cdc_user; ALTER PUBLICATION cdc_publication ADD TABLE public.flow_watermarks;
  1. Create the PostgreSQL capture. On the Sources page, click New Capture, choose PostgreSQL, name the capture, and fill in the endpoint properties:
    • Server Address: host:port. Allowlist Estuary's IP addresses, or use Network Tunnel for an SSH tunnel. Private and BYOC deployments can use private networking.
    • User and Database.
    • Authentication: Password, or AWS, Google Cloud or Azure IAM.
    • Advanced Options: Publication Name, Slot Name and Watermarks Table default to flow_publication, flow_slot and public.flow_watermarks. With the SQL above, set User to cdc_user and Publication Name to cdc_publication.
PostgreSQL capture form with Server Address, User, Database and Password filled in.
PostgreSQL Advanced Options: Publication Name cdc_publication, Slot Name, Watermarks Table.
  1. Select tables. Click Next. Estuary discovers the published tables and lists one binding per table under Output Collections.
    • Remove tables you don't want. Each binding writes its own collection.
    • Raise Backfill Priority to load key tables first; leave Backfill Mode at its default.
    • Keep Automatically keep schemas up to date on (see schema evolution).

Click Save and publish. The backfill starts, with change capture running alongside it.

PostgreSQL capture collections with schema evolution toggles on and Backfill Priority visible.
  1. Create the Snowflake materialization. On the capture's details page, click Materialize, choose Snowflake, and fill in the endpoint properties:
    • Host (Account URL): orgname-accountname.snowflakecomputing.com, no protocol.
    • Database, Schema, Warehouse and Role from the setup script.
    • Snowflake Timestamp Type: the type you chose above.
    • Authentication: Private Key (JWT), with User and Private Key.
    • Hard Delete: on to delete rows; off keeps soft deletes.
Snowflake endpoint form with Private Key (JWT) authentication selected.
  1. Choose the sync schedule. Under Sync Schedule, Sync Frequency defaults to 30 minutes. Shorter intervals give fresher tables and more warehouse resumes. For business-hours freshness, set Timezone, Fast Sync Start Time, Fast Sync Stop Time, and optionally Fast Sync Enabled Days; outside that window, syncs run every 4 hours. See sync schedules.
Snowflake Sync Schedule section with the Sync Frequency dropdown open.
  1. Map collections to tables. Click Next. When prompted for a naming convention, choose Match source structure to keep PostgreSQL schema and table names, or Set a default schema. Each binding under Source Collections has:
    • Table and Alternative Schema, to override names.
    • Delta Updates, for event tables. Tables with AUTOINCREMENT/IDENTITY or geospatial columns, column defaults or collations can't use Snowpipe Streaming.
    • Clustering Keys, for large, selectively filtered tables; clustering bills its own credits.
  2. Publish. Click Save and publish. Estuary creates the Snowflake tables and loads the backfill without waiting for the schedule.
  3. Verify that data is landing.
Snowflake materialization Overview: Connector Status Running and a Data Read chart.

How Estuary handles backfills, recovery and extra destinations

The capture writes every change to a collection: a durable log stored as JSON files in your cloud storage bucket. Destinations read the collection, not PostgreSQL, so rebuilding a Snowflake table or adding a destination doesn't touch the database. Only a lost slot makes the capture re-read it.

One collection for the backfill and the change stream

The backfill and ongoing WAL changes land in the same collection, one per table. By default the capture reads key-ordered backfill chunks while reading the WAL, so changes flow while large tables still backfill. The materialization commits its read checkpoint and the Snowflake table state together, so a crash mid-load neither applies a batch twice nor skips one.

One PostgreSQL capture writes a collection that feeds Snowflake and Elasticsearch.
PostgreSQL keeps one slot and serves one read; destination rebuilds read the collection.

What a re-backfill does and doesn't avoid

SituationRe-reads PostgreSQL?What to do
Snowflake table dropped, corrupted, or needs a new column typeNoMaterialization backfill: truncate and repopulate from the collection
Add a second destinationNoNew materialization; it reads history, then follows changes
Slot lost, planned, writes pausedWAL onlyCapture backfill in "Only Changes" mode; no table reads
Slot lost or WAL aged out, outage start knownChanged rows onlyIncremental backfill with Minimum Backfill XID or an Additional Backfill Filter
Slot lost, no reliable cutoffFull tablesFull incremental backfill; Snowflake tables are merged, not truncated
Collection key changesYesNew collection and a source re-read

The limits: replay depends on retention (trial storage keeps about 20 days; use your own bucket for full history); a WAL gap still needs a source read, because the collection can't supply changes it never received, though other materializations keep running meanwhile; and delta-update tables get duplicates from incremental backfills.

Snowflake and a low-latency store from one capture

To stream PostgreSQL changes to Snowflake and a real-time store from one pipeline, add a second materialization on the same collection. PostgreSQL keeps one slot and serves one read. Snowflake loads on its sync schedule, while database and streaming destinations such as PostgreSQL, Elasticsearch, MongoDB and DynamoDB apply changes as they arrive, with no schedule.

Schema evolution

The capture re-checks its tables' schemas every 15 minutes by default; auto-discovery can add new tables. New scalar columns are added to Snowflake. An incompatible type change rebuilds the Snowflake table from the collection by default; per binding, onIncompatibleSchemaChange can instead abort, disable the binding, or disable the task.

When another approach may make more sense

  • Snowflake-only, native tooling: the Openflow Connector for PostgreSQL reads logical replication (PostgreSQL 11–18), marks deletes in a _SNOWFLAKE_DELETED column, and runs on an Openflow runtime in Snowpark Container Services or your own AWS account. Its documented recovery after a lost slot is a fresh snapshot of the tables, unless writes were stopped first.
  • Debezium and Kafka already run well: add the Snowflake Kafka connector. It only inserts, so you build the dedup/MERGE layer; a lost slot means a new snapshot, and Snowflake rebuilds are limited by topic retention.
  • One-time migration or daily freshness on small tables: an export with COPY INTO, or a nightly updated_at query, avoids a slot on production. The cost is a full read or a query per table per run, and deleted rows never arrive.
  • Logical replication isn't available (some managed tiers, or replicas before PostgreSQL 16): use batch capture with an xmin or updated_at cursor, and accept that hard deletes are missed. Estuary's PostgreSQL Batch connector covers this case, polling by xmin on a schedule.

To try the route, follow the PostgreSQL CDC to Snowflake tutorial or create a PostgreSQL capture in the Estuary dashboard.

FAQs

    What if a PostgreSQL table has no primary key?

    It can still replicate, but MERGE has nothing to match on. You need a mode that backfills by physical row ID (ctid), or a key you declare in the pipeline.
    Some tools write watermark rows to a scratch table (Estuary's is public.flow_watermarks) so the backfill lines up exactly with the WAL stream. The replication slot, not a table, tracks the WAL position. A read-only mode drops the watermarks table but needs at least one captured table to change regularly; it is also how Estuary captures from a PostgreSQL 16+ standby.
    Unlogged and temporary tables. Neither is written to the WAL, so there is nothing to decode.

Start streaming your data for free

Build a pipeline

About the author

Picture of Seán Whelan
Seán WhelanSolutions Engineer

Seán Whelan is a Solutions Engineer at Estuary, helping customers design, troubleshoot, and run production data pipelines. Seán works across change data capture, database replication, and SaaS integrations, and writes about what actually comes up when real teams move data.

Real-Time & Batch Pipelines.Simple to Deploy.Simply Priced.

  • $0.50/GB of data moved + $100/month per connector instance
  • Simple usage-based pricing with volume discounts
  • Sub-100ms end-to-end latency for real-time pipelines