Estuary

Snowflake Change Data Capture (CDC): A Comprehensive Setup Guide

Learn how to set up change data capture pipelines within Snowflake, plus tips to build CDC pipelines into Snowflake.

snowflake CDC
Share this article

How to track changes to tables inside Snowflake with Streams and Tasks, how to stream changes into Snowflake from an operational database, how to move them back out, and the failure modes that catch teams in production.

Those are three different jobs. They use different machinery, fail in different ways, and a solution to one is no help with the others. This guide covers all three: working SQL for the native path, and the practical trade-offs for the other two.

Key Takeaways

  • Snowflake Streams track row-level changes inside your own account. A stream is a stream offset over table history, not a copy of the data.

  • Tasks run the apply on a schedule, usually a merge statement into a target table. Tasks are created suspended, so ALTER TASK ... RESUME is a required step.

  • Dynamic tables are the declarative alternative when all you want is a derived table kept current.

  • CDC into Snowflake needs a separate product. No stream reaches outside Snowflake, so the replication comes from Openflow or a third-party platform, each with its own bill.

  • CDC out of Snowflake means polling. A reverse ETL sync or a search index reads changes on a schedule, and every poll bills the warehouse.

  • A stale stream is the failure mode that costs you data. It is silent, and the unconsumed changes are unrecoverable.

Snowflake CDC means three different things

snowflake cdc - snowflake logo

Change data capture is the practice of capturing changes made to data in a source system and making those changes available to other systems. In a Snowflake context that splits three ways, according to where the data starts and where it has to end up.

  1. CDC inside Snowflake: You have a table in Snowflake and you want to know what changed since you last looked, so you can update a downstream table without rescanning the source. Snowflake handles this natively with Streams, usually driven by Tasks and applied with MERGE. Everything stays inside your account.
  2. CDC into Snowflake:Your data is in an operational database and you want it in Snowflake, kept current, without a nightly full reload. Snowflake SQL can't reach it, because a stream can't see a table it doesn't own. You need something that reads the source database's replication log and writes the changes in: a third-party platform, or Snowflake's own Openflow, which reached GA in November 2025 and now carries CDC connectors for PostgreSQL, MySQL, SQL Server, Oracle, and MongoDB. Either way it's a separate product with its own compute and its own bill, not a feature you switch on inside the warehouse.
  3. CDC out of Snowflake: Snowflake holds the modeled version of a record and something outside it needs that version: a reverse ETL sync pushing customer attributes into Salesforce, an operational store behind an application, a search index that has to reflect last night's transformations. The change tracking is the same native machinery as the first case. What differs is that the consumer sits outside Snowflake and reads on a schedule, which sets both the latency and the bill.

Side by side, the three differ in mechanism, cost, and how they fail:

 CDC inside SnowflakeCDC into SnowflakeCDC out of Snowflake
What it tracksTables in your own accountTables in an outside databaseTables in your own account, read by an outside consumer
MechanismStreams, Tasks, MERGE, Dynamic TablesOpenflow or a third-party tool reading the source's replication logStreams, polled and consumed by an outside tool
Native to SnowflakeYes, in SQLNot in SQL. Snowflake's Openflow does it as a separate productThe change tracking is native. Moving the data out isn't
Typical latencyBounded by the task schedule, from seconds upwardBounded by the apply cadence, from seconds to hoursBounded by the poll interval, minutes in practice
What it cannot seeAnything outside Snowflake. Streams are also unsupported on hybrid tablesNothing inherent, but delete visibility depends on the source and methodNothing outside the account, and nothing on hybrid tables
Main cost driverCompute for the apply: a serverless task at a 0.9x multiplier, or a warehouse with a 60-second minimum per resumeVendor pricing plus Snowflake ingest and applyWarehouse credits for every poll
Main failure modeA stale stream, which loses the unconsumed changes permanentlyBackfill outlasting the source's log retentionA stale stream, plus polling cost forcing the interval wider

If both the source and the target are Snowflake tables, the next two sections are yours. If the source is an outside database, go to "Streaming CDC into Snowflake from external databases". If Snowflake is the source and the target is somewhere else, go to "Streaming CDC out of Snowflake to other systems".

How CDC inside Snowflake works

Change tracking is the prerequisite

Before Snowflake can tell you what changed in a table, it has to be recording changes. That's a table property:

plaintext
ALTER TABLE members SET CHANGE_TRACKING = TRUE;

You rarely set it by hand, because creating a stream on a table enables it implicitly. You still want to know it exists: turning it on adds hidden columns to the table that store change metadata, at a small storage cost.

What a stream actually is

A stream is a stream offset, not a copy. It records a position in a table's version history and returns the rows that changed between that position and the table's current state. It stores no table data of its own.

snowflake cdc - data stream
Image Source

The distinction is not academic. Because a stream is an offset over Time Travel history rather than a log reader, its ability to return changes is bounded by how long Snowflake retains that history. Let the offset fall outside that window and the changes are gone permanently, which is the single most common way a Snowflake CDC pipeline breaks. Preventing that is the limitations section's first entry.

The three stream types

plaintext
CREATE STREAM member_check ON TABLE members;  -- standard CREATE STREAM member_appends ON TABLE members APPEND_ONLY = TRUE; CREATE STREAM file_arrivals ON EXTERNAL TABLE ext_files INSERT_ONLY = TRUE;

Standard is the default and covers most pipelines. Append-only is a cost optimization. Insert-only is required rather than chosen: external tables support no other type, and neither do externally-managed Iceberg tables below spec V3. The support lists differ in one place that catches people: standard streams work on directory tables, append-only streams don't.

Stream typeCapturesSupported onWhen to use it
Standard (delta)Inserts, updates, and deletes, as a net delta between two offsetsTables, views, directory tables, dynamic tables, Snowflake-managed Iceberg, externally-managed V3 IcebergThe default. Any pipeline that has to reflect updates and deletes downstream
Append-onlyInserts only. Updates and deletes are invisibleStandard tables, dynamic tables, Snowflake-managed Iceberg, viewsAppend-style ELT. Cheaper, because it skips the delta computation
Insert-onlyInserts only, with no delete recordsExternal tables, externally-managed Iceberg, and Delta Direct tables without partition columns (required)When the source is an external or externally-managed table. A replaced file reads as new rows

The metadata columns

Every stream exposes three columns alongside the table's own:

ColumnValuesWhat it tells you
METADATA$ACTIONINSERT or DELETEThe physical operation. There is no UPDATE value.
METADATA$ISUPDATETRUE or FALSEWhether this row is part of an update.
METADATA$ROW_IDopaque identifierStable row identity, so you can track one row across changes.

An update is represented as a DELETE and an INSERT pair, both carrying METADATA$ISUPDATE = TRUE. The DELETE row holds the old values, the INSERT row holds the new ones.

Filtering on METADATA$ACTION = 'DELETE' and treating those rows as deletions will therefore delete rows that were only updated, and it corrupts data rather than throwing an error. The correct test for a real deletion is:

plaintext
WHERE METADATA$ACTION = 'DELETE' AND METADATA$ISUPDATE = FALSE

The offset only moves when you consume the stream

Querying a stream does not advance its offset. A SELECT against a stream returns the same rows every time you run it, so a stream is safe to inspect without disturbing the pipeline that consumes it. The offset moves only when the stream is consumed inside a DML transaction: a MERGE, an INSERT ... SELECT, a CREATE TABLE AS SELECT, or a COPY INTO <location> that reads from it. There's one offset per stream, which is why each independent consumer needs its own.

Within an explicit transaction, statements see the stream as of the offset when the transaction began, and the offset advances only on successful commit. That's what makes the MERGE apply idempotent: if it fails, the offset doesn't move and the same changes are still there on the retry.

Tasks run the apply

A stream tells you what changed. A task is what runs on a schedule and does something about it.

plaintext
CREATE TASK apply_member_changes SCHEDULE = '1 MINUTE' WHEN SYSTEM$STREAM_HAS_DATA('member_check') AS MERGE INTO members_prod p USING member_check c ON p.id = c.id ...;

WHEN SYSTEM$STREAM_HAS_DATA(...) skips the body when there's nothing to do, so the task doesn't run a MERGE against an empty stream every minute. The requirement most likely to trip you up isn't in the definition at all: tasks are created suspended, so ALTER TASK apply_member_changes RESUME; is a required step. A task that appears to do nothing has usually never been resumed.

Which kind of task you get is decided by whether you name a warehouse. Omitting WAREHOUSE makes the task serverless, so Snowflake sizes the compute, at a 0.9 credit multiplier; a Serverless Tasks Flex variant runs at 0.5 for work that tolerates loose scheduling. Naming a warehouse makes it user-managed, billing standard virtual warehouse credits with a 60-second minimum every time the warehouse resumes.

The privileges differ too, and this is the step that stops a first attempt. A serverless task needs the account-level EXECUTE MANAGED TASK privilege; a user-managed task needs EXECUTE TASK plus USAGE on the warehouse.

The multiplier and the per-resume minimum decide most apply-cost questions. Serverless is the cheaper option for an apply that runs briefly and often, and a dedicated warehouse becomes competitive only when it's busy enough that the per-resume minimum is a small fraction of its running time. If you've read elsewhere that serverless tasks carry a 1.5x premium, that figure is out of date.

Dynamic tables, the declarative alternative

If all you want is a derived table kept current, you may not need a stream or a task at all:

plaintext
CREATE DYNAMIC TABLE members_current TARGET_LAG = '5 minutes' WAREHOUSE = etl_wh AS SELECT id, name, fee FROM members WHERE fee > 0;

You declare the query and the freshness target, and Snowflake maintains it incrementally. No offset to manage, no staleness to monitor, no hand-written merge statement.

The trade-offs are real. TARGET_LAG has a 60-second floor, and a dynamic table can't be modified by direct DML. REFRESH_MODE defaults to AUTO, which resolves to INCREMENTAL or FULL once at creation and never re-evaluates. If it resolved to incremental and a later edit makes incremental refresh impossible, the refreshes fail instead of falling back to full. That's the safer failure, provided someone is watching the refresh history. Changing the mode afterwards needs CREATE OR ALTER DYNAMIC TABLE or CREATE OR REPLACE, because ALTER DYNAMIC TABLE can't do it.

Reach for dynamic tables when you're expressing a transformation. Reach for streams and tasks when you need control over the apply, non-trivial delete handling, or a query shape that won't refresh incrementally.

The CHANGES clause, for one-off questions

plaintext
SELECT * FROM members CHANGES (INFORMATION => DEFAULT) AT (TIMESTAMP => '2026-07-30 00:00:00'::TIMESTAMP_LTZ);

CHANGES gives you the same metadata columns over an explicit time interval, with no durable offset and no consumption. It's the right tool for "what changed between Tuesday and Thursday", for backfills, and for several consumers that each need the same interval. It's bounded by Time Travel retention, which defaults to 1 day, and the extension that protects an unconsumed stream doesn't apply here because there's no stream to protect.

Setting up CDC in Snowflake with Streams and Tasks

The example is a gym. members holds who's joined and what they pay, signup records the date each one joined, and everyone starts on a thirty-day free trial at a fee of zero. What you're building is a second table that keeps up with the fee changes without rescanning members each time: a stream to record what changed, and a task to apply it on a schedule.

1. Create the database and tables

plaintext
CREATE OR REPLACE DATABASE cdc_demo; USE DATABASE cdc_demo; CREATE OR REPLACE TABLE members ( id NUMBER(8)  NOT NULL, name VARCHAR(255) DEFAULT NULL, fee  NUMBER(3)  NULL ); CREATE OR REPLACE TABLE signup ( id NUMBER(8), dt DATE );

2. Create the stream

plaintext
CREATE OR REPLACE STREAM member_check ON TABLE members;

This also enables change tracking on members. The stream's offset starts at the table's current version, which is empty.

3. Load some data

plaintext
INSERT INTO members (id, name, fee) VALUES (1, 'Joe',  0), (2, 'Jane', 0), (3, 'George', 0), (4, 'Betty',  0), (5, 'Sally',  0); INSERT INTO signup (id, dt) VALUES (1, '2026-01-01'), (2, '2026-02-15'), (3, '2026-05-01'), (4, '2026-07-16'), (5, '2026-08-21');

4. Look at the stream

plaintext
SELECT id, name, fee, METADATA$ACTION, METADATA$ISUPDATE FROM member_check ORDER BY id;
IDNAMEFEEMETADATA$ACTIONMETADATA$ISUPDATE
1Joe0INSERTFALSE
2Jane0INSERTFALSE
3George0INSERTFALSE
4Betty0INSERTFALSE
5Sally0INSERTFALSE

Five inserts, none of them updates. Run this query again and you'll get the same five rows: a SELECT doesn't advance the offset.

5. Consume the stream

members_prod is the second table, the one that has to keep up with fee changes. Create it, then populate it from the stream:

plaintext
CREATE OR REPLACE TABLE members_prod ( id NUMBER(8)  NOT NULL, name VARCHAR(255) DEFAULT NULL, fee  NUMBER(3)  NULL ); INSERT INTO members_prod (id, name, fee) SELECT id, name, fee FROM member_check WHERE METADATA$ACTION = 'INSERT';

That's a DML statement reading the stream, so the offset advances. Query member_check now and it's empty. The five inserts have been accounted for.

One caution before you reuse this pattern: consuming a stream advances the offset past everything in it, including rows your WHERE clause filtered out. The filter is harmless here, since every row is an insert. On a stream holding both inserts and deletes it would consume the deletes and discard them. Read the stream once into a staging table and branch from there, or handle every case in one MERGE.

6. Update the source

Trials end thirty days after signup, so anyone who joined more than thirty days ago owes a fee. The query uses a fixed date, 2026-08-15, in place of today's, so the example gives the same result whenever you run it:

plaintext
MERGE INTO members m USING ( SELECT id FROM signup WHERE DATEDIFF(day, dt, '2026-08-15'::DATE) > 30 ) s ON m.id = s.id WHEN MATCHED THEN UPDATE SET m.fee = 90;

DATEDIFF(day, dt, '2026-08-15') returns the number of days from each signup date to that fixed date, so > 30 selects the members whose trial has already run out:

IDNAMESigned upDays to 2026-08-15Trial over?
1Joe2026-01-01226Yes
2Jane2026-02-15181Yes
3George2026-05-01106Yes
4Betty2026-07-1630No, exactly on the boundary
5Sally2026-08-21-6No, hasn't joined yet

Betty is the row to watch. Thirty days is not more than thirty days, so she keeps the free rate. That's the difference between > and >=.

7. See how the stream represents updates

plaintext
SELECT id, name, fee, METADATA$ACTION, METADATA$ISUPDATE FROM member_check ORDER BY id, METADATA$ACTION DESC;
IDNAMEFEEMETADATA$ACTIONMETADATA$ISUPDATE
1Joe90INSERTTRUE
1Joe0DELETETRUE
2Jane90INSERTTRUE
2Jane0DELETETRUE
3George90INSERTTRUE
3George0DELETETRUE

Three updates, six rows. Each pair shares an id, carries METADATA$ISUPDATE = TRUE, and gives you the before value and the after value.

This pairing is only visible because the stream was consumed in step 5. Had you skipped that step, the stream would still be measuring from an empty table and would show five plain inserts carrying the current values, with no update pairs at all. A standard stream reports the net change since its offset, not a replay of every operation.

8. Automate the apply with a task

plaintext
CREATE OR REPLACE TASK apply_member_changes SCHEDULE = '1 MINUTE' WHEN SYSTEM$STREAM_HAS_DATA('member_check') AS MERGE INTO members_prod p USING ( SELECT id, name, fee, METADATA$ACTION, METADATA$ISUPDATE FROM member_check QUALIFY ROW_NUMBER() OVER ( PARTITION BY id ORDER BY IFF(METADATA$ACTION = 'INSERT', 1, 0) DESC ) = 1 ) c ON p.id = c.id WHEN MATCHED AND c.METADATA$ACTION = 'DELETE' AND c.METADATA$ISUPDATE = FALSE THEN DELETE WHEN MATCHED THEN UPDATE SET p.name = c.name, p.fee = c.fee WHEN NOT MATCHED AND c.METADATA$ACTION = 'INSERT' THEN INSERT (id, name, fee) VALUES (c.id, c.name, c.fee); ALTER TASK apply_member_changes RESUME;

The QUALIFY collapses each update's DELETE/INSERT pair to one row, keeping the INSERT, which is the after image. Without it the MERGE fails outright, which is covered below. A row that was genuinely deleted produces only a DELETE, so it survives the filter and hits the DELETE branch. The IFF in the sort is deliberate: ORDER BY METADATA$ACTION DESC gives the same result, but only because INSERT happens to sort after DELETE alphabetically, and that isn't obvious to whoever reads the query six months from now.

This task omits WAREHOUSE, so it runs on serverless compute and your role needs EXECUTE MANAGED TASK. Add WAREHOUSE = <name> instead to run it on a warehouse you control, with EXECUTE TASK and USAGE on that warehouse. The RESUME isn't optional, and SCHEDULE accepts values down to 10 seconds if you need the apply tighter than a minute.

Once the task has run, the target table reflects the update and the stream is empty again:

plaintext
SELECT id, name, fee FROM members_prod ORDER BY id;
IDNAMEFEE
1Joe90
2Jane90
3George90
4Betty0
5Sally0

Joe, Jane, and George carry the new fee. Betty and Sally were never in the change set, so the MERGE left them alone. That's the whole cycle: a stream accumulates changes, a task consumes them, and the target catches up without anyone rescanning members.

9. Check the stream isn't going stale

plaintext
SHOW STREAMS LIKE 'member_check'; SELECT "name", "stale", "stale_after" FROM TABLE(RESULT_SCAN(LAST_QUERY_ID()));
namestalestale_after
MEMBER_CHECKfalse2026-08-29 09:22:41.000 -0700

stale_after is the deadline. Consume the stream before then or the pending changes are lost. SELECT SYSTEM$STREAM_HAS_DATA('member_check'); returns whether anything is waiting, and calling it on an empty stream also keeps that stream from going stale.

Snowflake CDC best practices

  1. Alert on stale_after approaching, not just on task failures: A suspended task and a healthy pipeline look identical from the outside until the data is gone.
  2. Dedupe before you merge: Collapse each key to one row with QUALIFY ROW_NUMBER(). On a Snowflake stream, rank on IFF(METADATA$ACTION = 'INSERT', 1, 0) DESC. On change data from outside Snowflake, order by the source sequence (an LSN, a binlog coordinate, a commit timestamp), never by arrival time.
  3. Batch the apply to the freshness you actually need: Every MERGE on a user-managed task pays the 60-second warehouse minimum. Running every five minutes instead of every minute can cut the cost of the apply substantially while changing the business outcome not at all.
  4. Set SUSPEND_TASK_AFTER_NUM_FAILURES: It defaults to 0, which means disabled, so a task can fail every minute indefinitely. Pair it with monitoring on TASK_HISTORY().

There's more on getting the fundamentals right in the guide to CDC done correctly.

Snowflake CDC limitations and common mistakes

Roughly in order of what they cost you.

A stale stream is data loss, not an error. If a stream's offset falls outside the source table's retention window, the unconsumed changes are gone. You can't recover them. You recreate the stream, which resets the offset to now, and backfill the gap by other means.

The nuance that saves people: if a table's DATA_RETENTION_TIME_IN_DAYS is under 14, Snowflake temporarily extends the effective retention to keep an unconsumed stream alive, bounded by MAX_DATA_EXTENSION_TIME_IN_DAYS (default 14). So a stream on a table with 1-day Time Travel typically survives around fourteen days unconsumed, not one. Don't rely on it, but don't panic over a long weekend either. Check STALE_AFTER rather than reasoning from the retention setting, since that's the value Snowflake computed for your stream. That matters most on Standard Edition, where DATA_RETENTION_TIME_IN_DAYS can only be 0 or 1.

Recreating a source table orphans its streams. CREATE OR REPLACE TABLE resets version history. Every stream on that table breaks. If your deployment process recreates tables, it needs to recreate the streams too.

Two consumers sharing one stream will lose data. Whichever job consumes first advances the offset past changes the other never saw. Nothing errors, so it surfaces weeks later when someone notices two tables disagree. Give each consumer its own stream.

A scheduled SELECT is not a consumer. Teams point a query at a stream, watch it return the same rows forever, and conclude the pipeline is broken. Only DML advances the offset.

MERGE fails on duplicate keys by default. ERROR_ON_NONDETERMINISTIC_MERGE defaults to TRUE, so if one target row matches several source rows the statement errors. Setting it to FALSE is worse than the error: Snowflake then applies one matching row arbitrarily and you get wrong data with no warning. Dedupe with QUALIFY instead.

There is no METADATA$ACTION = 'UPDATE'. Filtering DELETE rows without checking METADATA$ISUPDATE deletes updated rows.

Hybrid tables don't support streams. Snowflake lists streams under the unsupported features for hybrid tables, so if you're running a Unistore workload and want CDC off it, the native path doesn't exist. You route through the analytic side or capture from the upstream operational database instead. Time Travel on hybrid tables is also restricted to AT (TIMESTAMP => ...), with OFFSETSTATEMENT, and STREAM unsupported.

Schema changes need handling. Adding a column to a source table doesn't break a stream, but a downstream MERGE with an explicit column list won't pick it up. Dropping or retyping a column can break the apply outright.

Streaming CDC into Snowflake from external databases

Everything above has one ceiling: a stream only watches an object in its own account, so it can't reach a PostgreSQL table. (Streams come back into play in the other direction, which the next section covers.)

Whatever you use to bridge the gap, the shape is the same:

  1. Capture. Read the source's log: the write-ahead log in PostgreSQL, the binary log in MySQL, the oplog behind MongoDB change streams, or SQL Server's transaction log.
  2. Backfill. Take a consistent snapshot of what's already there, then switch to streaming from the position recorded before the snapshot started, so nothing is lost or double-counted.
  3. Land. Write the change records into Snowflake.
  4. Apply. Merge them into the modeled table, keyed on the primary key.

How the data actually lands

Three Snowflake ingestion mechanisms, in increasing order of freshness, plus the managed option that sits on top of one of them:

PathGranularityTypical latencyBilling
Scheduled COPY INTOFilesMinutes to hoursWarehouse credits
SnowpipeFilesWithin minutes, no guarantee0.0037 credits per GB
Snowpipe StreamingRowsTypically under 10 seconds0.0037 credits per uncompressed GB
Managed CDC platformRows or micro-batchesSeconds to minutes, set by the apply cadenceVendor pricing plus Snowflake ingest and apply

Snowpipe is micro-batch, not streaming: it still ingests whole files, just triggered automatically by cloud storage notifications. Its billing is now a flat 0.0037 credits per GB, replacing a model that charged for compute per second per core and added a per-1,000-files fee. Both are gone, so the traditional advice that many small files are expensive is now about latency and throughput rather than cost.

Snowpipe Streaming writes rows directly with no intermediate files, and its high-performance architecture reached GA across AWS, Azure, and GCP between September and November 2025. One distinction affects which path you pick: the per-GB rate above applies to that high-performance architecture only. The older classic architecture (the Java ingest SDK, version 4.x and earlier) bills on two axes instead, Snowflake-managed compute at a 1x multiplier plus 0.01 credits per client instance per hour, so an open client costs you whether or not it's sending much. Snowflake has planned the classic path for deprecation, so new work should target the high-performance one.

A managed CDC platform will generally use one of these three methods for Snowflake ingestion. Make sure you understand which one the platform uses behind the scenes so you know what sort of latency and cost to expect.

Applying the changes

Almost every tool, including Snowflake's own, uses the same pattern: land raw change records in a staging or journal table, then MERGE into the modeled table.

Deletes are either applied literally or recorded with a soft-delete flag. Soft deletes preserve history, stop a late-arriving update from resurrecting a deleted row, and let downstream incremental consumers still see the deletion as a change. Snowflake's own Openflow connectors default to soft deletes.

Apply cadence sets your real freshness. This is the number that gets misrepresented most often. A tool can capture a change from PostgreSQL in milliseconds, but if the MERGE into Snowflake runs every thirty minutes, the data is thirty minutes old, and only that second number matters to whoever's reading the dashboard. When you compare vendors, the apply interval is the number to ask for.

For a fuller treatment of keeping backfill and streaming consistent, see the reference architecture for CDC.

Streaming CDC out of Snowflake to other systems

The outbound direction has one mechanism available, and it's the one covered earlier in this guide. Snowflake exposes no transaction log to third parties, so there's no equivalent of the PostgreSQL WAL, the MySQL binary log, or the MongoDB oplog for an outside tool to tail. A consumer creates a stream on the table it cares about, polls it, consumes it inside a DML transaction so the offset advances, and ships the result out. That's what a Snowflake source connector is doing underneath. The CHANGES clause is the other option, and it suits a consumer that would rather name its own interval than manage an offset.

  • Every poll costs warehouse credits: Reading a stream runs a query, and a query resumes a warehouse for a minimum of sixty seconds. Polling every minute means keeping a warehouse effectively always on, so the poll interval is a budget decision before it's a freshness one.
  • This isn't the same class as log-based CDC: A capture from PostgreSQL reads committed changes as they're written. A capture from Snowflake asks a warehouse, on a schedule, what changed. The two don't belong in the same latency bracket.
  • Staleness applies in this direction too: The offset sits in Time Travel history, so an outbound stream that stops being consumed loses its unconsumed changes exactly as an internal one does. And because each consumer needs its own stream, a table feeding both an internal task and an external tool needs two.

One Snowflake-specific limitation catches people. If the table arrives through Secure Data Sharing, the provider has to enable change tracking and set the retention period on their side, and shared tables can't be zero-copy cloned, so the initial snapshot is a full copy. A stream on a shared table doesn't extend the provider's retention either, which means your staleness deadline is set by a value you don't control.

If the destination is an operational system rather than an analytical store, the reverse ETL guide covers the pattern in more depth.

How Estuary supports Snowflake CDC

Estuary is a right-time data platform: stream in real time when it matters most, batch when it doesn’t, on one managed system. Estuary covers both directions, as a destination for change data and as a source of it. Many tools cover only one, and two widely used ones don't write to Snowflake natively at all: AWS DMS lists no Snowflake target, and Google Datastream supports BigQuery, Cloud Storage, and Iceberg. Both reach Snowflake only through an object-storage hop you build and maintain yourself.

Snowflake as a destination

The Snowflake materialization connector uploads change data to a Snowflake table stage and then transactionally applies it to the target table.

  • Authentication is key-pair JWT only. Username and password authentication was deprecated in April 2025, in line with Snowflake's own move against password auth for service users. The setup script creates a Snowflake user with TYPE = SERVICE.
  • Standard bindings are fully reduced. The connector queries the destination by key and merges, so the table ends up with one row per key.
  • Delta-update bindings skip that query, which lowers latency and cost on large tables at the cost of a table that isn't fully reduced, so duplicate keys can appear and something downstream has to resolve them. That's the right trade when the key is already unique or the data is append-only.
  • Snowpipe Streaming is used by default for delta-update bindings, and only those. Standard bindings go through the table stage on the sync schedule.
  • The sync schedule is the cost lever.syncFrequency defaults to 30 minutes and can be set anywhere from 0s upward, with a configurable faster window during business hours. Longer intervals mean fewer warehouse resumes, which cuts the Snowflake bill without changing what Estuary charges, while 0s (or “as fast as possible”) is useful to pair with Snowpipe Streaming for real-time workflows.

Livble runs this path in production and cut its Snowflake costs 50%. There's a runnable PostgreSQL to Snowflake tutorial if you want to work through a pipeline end to end, and a Snowflake destination overview with the supported sources.

Snowflake as a source

The Snowflake CDC capture connector captures changes out of Snowflake tables. It creates and manages the streams and staging tables itself, in a dedicated schema that defaults to ESTUARY_STAGING, so you don't hand-roll the objects covered earlier in this guide. Setup grants the capture role usage on a warehouse, CREATE SCHEMAMONITOR and USAGE on the database, and USAGE plus SELECT on every current and future schema and table in it. That last grant is broader than most people read it as, so narrow it if you only mean to expose some tables. Captured changes then go to any materialization in the catalog, so a reverse ETL sync into an operational system and a copy into a second warehouse are the same pipeline with a different destination.

Be clear about what this is: it's built on Snowflake Streams, polled on an interval, and metered by your warehouse. It isn't log tailing, and it isn't in the same latency class as capturing from PostgreSQL or MySQL. The default poll interval is 5 minutes, and shortening it buys fresher data at a higher Snowflake bill. Estuary's own documentation is direct about the ceiling: keeping a compute warehouse active around the clock "can be prohibitively expensive for many users." That's a property of Snowflake rather than of any particular vendor.

Snowflake CDC tools compared

ToolCDC mechanismHow it lands in SnowflakePricing modelDeletes
EstuaryLog-based capture; Snowflake Streams for Snowflake-as-sourceTable stage then transactional apply; Snowpipe Streaming for delta-update bindingsPer GB moved plus per connectorCaptured
Snowflake OpenflowFirst-party Apache NiFi connectorsJournal table then MERGE, native ingestSnowflake credits: compute, ingest, and telemetrySoft delete
FivetranLog-based, plus HVR for heavy on-premises sourcesStaged files, batch merge on syncMonthly active rowsSoft delete by default; SCD Type 2 in History Mode
AirbyteDebezium-based for most databasesStaged files, typed and deduped mergeVolume or capacity tiersSoft delete option
Debezium and KafkaLog-based, self-operatedSnowflake Kafka Connector, via Snowpipe or Snowpipe StreamingNo license; you run the infrastructureTombstone events; the sink decides
StriimLog-based, streamingSnowpipe StreamingvCPU-hour plus data volumeConfigurable
Confluent CloudManaged Debezium connectorsSnowflake sink connector, or Tableflow to IcebergCluster, connector, and throughputTombstone events; the sink decides

The pricing model shapes the bill more than the feature list does, because it's what changes at scale. Row-based pricing and volume-based pricing diverge hardest on tables with heavy update churn: a row updated fifty times a month is one active row but fifty change events worth of bytes. Neither is universally cheaper. It depends on the shape of your workload.

Two notes on Snowflake's own tooling. Openflow is a serious option if you're consolidating on Snowflake governance and billing, though its costs stack, and they include an always-on management pool that bills even when idle. There’s a fuller comparison in Openflow vs. Estuary. Snowflake's native PostgreSQL and MySQL connectors remain in preview, and Snowflake's documentation states that moving them to general availability isn't on the roadmap, pointing users to Openflow instead. They're not the strategic path.

For a wider survey, see the roundup of the best change data capture tools.


Estuary is the right-time data platform that replaces fragmented data stacks by consolidating CDC, streaming, batch, and pipelines into a single managed system. Start Building For Free or explore the platform.

FAQs

    What is Snowflake CDC?

    Snowflake CDC is the practice of capturing changes to data and making them available to something else, in a Snowflake context. The term covers three jobs with different machinery. Tracking changes to tables already in your Snowflake account is a SQL problem, handled by Streams and Tasks. Replicating changes into Snowflake from an operational database such as PostgreSQL, MySQL, SQL Server, or MongoDB needs a separate tool, either a third-party platform or Snowflake's own Openflow. Moving changes out of Snowflake to another system, for reverse ETL or to feed an operational store, uses those same Streams, read on a poll by an outside tool rather than consumed in place by a Task.
    A stream records an offset in a table's version history and returns the rows that changed since that point, exposed with METADATA$ACTION, METADATA$ISUPDATE, and METADATA$ROW_ID columns. A task runs on a schedule, checks SYSTEM$STREAM_HAS_DATA to skip empty runs, and applies those changes to a target table with MERGE. Because the MERGE consumes the stream inside a transaction, the offset advances only when the apply succeeds, which makes the whole cycle safe to retry.
    A stream goes stale when its offset falls outside the source table's data retention period, and the unconsumed changes are then unrecoverable. The window is longer than the retention setting suggests: if DATA_RETENTION_TIME_IN_DAYS is under 14, Snowflake extends the effective retention to keep an unconsumed stream alive, bounded by MAX_DATA_EXTENSION_TIME_IN_DAYS (default 14), so a stream on a 1-day table typically survives about fourteen days rather than one. That matters most on Standard Edition, where the retention setting can only be 0 or 1. Read the real deadline from SHOW STREAMS as STALE_AFTER rather than calculating it yourself, and alert on it approaching.
    Use a CDC tool that reads your source database's replication log, takes an initial backfill, then streams subsequent changes into Snowflake through Snowpipe Streaming or a staged apply. Snowflake SQL can't reach an outside database on its own, because a stream only watches an object in its own account. Whichever tool you pick, the apply interval rather than the capture speed sets your real freshness, so set it to what you need and price it accordingly.
    ETL describes what happens to data (extract, transform, load), while CDC describes how changes are detected at the source. They aren't alternatives. CDC is a capture method that feeds an ETL or ELT process: instead of extracting a full table on a schedule, you extract only the rows that changed since the last run. Most modern pipelines pair the two, capturing changes from the source log and transforming them after they land.

Start streaming your data for free

Build a Pipeline

About the author

Picture of Emily Lucek
Emily LucekDeveloper Advocate / Data Engineer

Emily is an engineer and technical content creator with an interest in developer education. At Estuary, she works with data pipelines for both streaming and batch data and finds satisfaction in transforming a mess of information into usable data. Previous roles familiarized her with FinTech data and working closely with REST APIs.

Streaming Pipelines.
Simple to Deploy.
Simply Priced.
$0.50/GB of data moved + $.14/connector/hour;
50% less than competing ETL/ELT solutions;
<100ms latency on streaming sinks/sources.