<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pipeline Whiteboard</title>
    <description>The latest articles on DEV Community by Pipeline Whiteboard (@pipelinewhiteboard).</description>
    <link>https://dev.to/pipelinewhiteboard</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4201397%2F0349c0fc-27bf-474f-ba5c-cad4a96557de.png</url>
      <title>DEV Community: Pipeline Whiteboard</title>
      <link>https://dev.to/pipelinewhiteboard</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pipelinewhiteboard"/>
    <language>en</language>
    <item>
      <title>Change Data Capture (CDC) Explained: From the Transaction Log to a Snowflake MERGE</title>
      <dc:creator>Pipeline Whiteboard</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:50:08 +0000</pubDate>
      <link>https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582</link>
      <guid>https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582</guid>
      <description>&lt;p&gt;Your sales dashboard says there are 500 units in stock. Run a query against production and you get 12. Nobody broke anything. The nightly batch job ran nine hours ago, and in those nine hours the product sold out.&lt;/p&gt;

&lt;p&gt;Change Data Capture (CDC) is the standard way to close that gap without hammering your production database. This post walks through how it works, what the events look like, a real Postgres to Snowflake pipeline, and the mistakes that tend to show up only once the pipeline is live.&lt;/p&gt;

&lt;p&gt;Prefer to watch? Here's the 6-minute video version with animated diagrams:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/1NCUrWKV1Mo" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Video link: &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;Change Data Capture (CDC) Explained in 6 Minutes&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What CDC actually means
&lt;/h2&gt;

&lt;p&gt;CDC means you treat every row-level change in a source database as an event. When a row is inserted, updated or deleted, you capture that change &lt;strong&gt;in the order it was committed&lt;/strong&gt; and send it to whatever needs it downstream: a warehouse like Snowflake, a search index, a cache, or another service.&lt;/p&gt;

&lt;p&gt;The point is to stop copying the whole table every run. Batch reloads move the entire table to find a handful of differences. CDC moves only the rows that changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database already keeps a ledger
&lt;/h2&gt;

&lt;p&gt;Think of a bank vault. One way to know the balance is to walk in every night and count every note. That's slow, it gets in everyone's way, and the number is out of date by morning. The other way is to read the ledger: every deposit and every withdrawal, in order. Replay the ledger and you always know the balance.&lt;/p&gt;

&lt;p&gt;Databases already keep that ledger. Before a change is considered durable, the engine writes it to a transaction log, which it uses for crash recovery, replication, or both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Its "ledger"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Write-ahead log (WAL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;Binary log (binlog)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle&lt;/td&gt;
&lt;td&gt;Redo log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server&lt;/td&gt;
&lt;td&gt;Transaction log&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Log-based CDC reads this log, much like a read replica does, and turns it into a stream of change events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to capture changes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Query-based (polling).&lt;/strong&gt; Every few minutes you run something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;last_run&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's easy to build, but it has three blind spots. It can't see hard deletes, because a deleted row no longer matches anything. It only sees the latest state at poll time, so intermediate updates between polls are lost. And it relies on every writer setting &lt;code&gt;updated_at&lt;/code&gt; correctly, including the one-off script someone ran last quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Trigger-based.&lt;/strong&gt; Triggers on the source table copy each change into a shadow table such as &lt;code&gt;orders_changes&lt;/code&gt;. That catches deletes, but every write in production now does extra work inside the same transaction, and you own a set of triggers that has to keep up with schema changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Log-based.&lt;/strong&gt; A connector reads the transaction log directly. It sees inserts, updates and deletes, in commit order, with very little extra load on the source. That's why it's the default today, and why tools such as &lt;strong&gt;Debezium&lt;/strong&gt;, &lt;strong&gt;AWS Database Migration Service (DMS)&lt;/strong&gt; and &lt;strong&gt;Google Datastream&lt;/strong&gt; work this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a change event
&lt;/h2&gt;

&lt;p&gt;Here is a simplified Debezium-style event for the stock example, showing an update to one inventory row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"product_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"product_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"connector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgresql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"db"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inventory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lsn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24023128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"txId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7781&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ts_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1761123456789&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1761123456912&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;op&lt;/code&gt;&lt;/strong&gt;: the operation. &lt;code&gt;c&lt;/code&gt; = create (insert), &lt;code&gt;u&lt;/code&gt; = update, &lt;code&gt;d&lt;/code&gt; = delete, &lt;code&gt;r&lt;/code&gt; = read, meaning a row emitted during the initial snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;before&lt;/code&gt; / &lt;code&gt;after&lt;/code&gt;&lt;/strong&gt;: the row state on each side of the change. For a delete, &lt;code&gt;after&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;; for an insert, &lt;code&gt;before&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;. One Postgres caveat: by default &lt;code&gt;before&lt;/code&gt; only contains the full old row if the table uses &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt;. Otherwise you get the key columns or nothing at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source&lt;/code&gt;&lt;/strong&gt;: where the change came from. &lt;code&gt;lsn&lt;/code&gt; is the position in the log and &lt;code&gt;source.ts_ms&lt;/code&gt; is when the change was committed in the database (the top-level &lt;code&gt;ts_ms&lt;/code&gt; is when the connector processed it). This metadata lets you order events correctly and resume from the right spot after a restart.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A real pipeline: Postgres on RDS to Snowflake
&lt;/h2&gt;

&lt;p&gt;Here's a setup that comes up a lot. An online shop runs PostgreSQL on Amazon RDS, and the analytics team wants orders in Snowflake within minutes instead of the next morning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PostgreSQL (RDS) -&amp;gt; AWS DMS -&amp;gt; S3 (Parquet) -&amp;gt; Snowpipe -&amp;gt; Snowflake staging -&amp;gt; MERGE -&amp;gt; orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 1: enable logical replication.&lt;/strong&gt; In the RDS parameter group, set &lt;code&gt;rds.logical_replication = 1&lt;/code&gt;. That sets &lt;code&gt;wal_level = logical&lt;/code&gt;, so the WAL contains row-level changes a connector can decode. It's a static parameter, so plan for a reboot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: create a DMS task with migration type "full load and ongoing replication"&lt;/strong&gt; (&lt;code&gt;full-load-and-cdc&lt;/code&gt; in the API). DMS copies the existing rows first, then keeps streaming changes from the WAL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: write changes to S3 as Parquet.&lt;/strong&gt; Each CDC row has an &lt;code&gt;Op&lt;/code&gt; column: &lt;code&gt;I&lt;/code&gt; for insert, &lt;code&gt;U&lt;/code&gt; for update, &lt;code&gt;D&lt;/code&gt; for delete. Two S3 endpoint settings are worth turning on: &lt;code&gt;TimestampColumnName&lt;/code&gt;, which adds a timestamp column to every row (the commit time for CDC rows, the load time for full-load rows), and &lt;code&gt;IncludeOpForFullLoad&lt;/code&gt;, so full-load rows also carry &lt;code&gt;Op = 'I'&lt;/code&gt; instead of having no &lt;code&gt;Op&lt;/code&gt; value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: load and merge in Snowflake.&lt;/strong&gt; Snowpipe (or a scheduled &lt;code&gt;COPY INTO&lt;/code&gt;) loads the files into a staging table. A &lt;code&gt;MERGE&lt;/code&gt; then applies them to the final table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;MERGE&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;commit_ts&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders_stage&lt;/span&gt;
    &lt;span class="c1"&gt;-- keep only the latest change per key in this batch&lt;/span&gt;
    &lt;span class="n"&gt;QUALIFY&lt;/span&gt; &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
        &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;commit_ts&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'D'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="k"&gt;DELETE&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit_ts&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'D'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit_ts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;QUALIFY ROW_NUMBER()&lt;/code&gt; step collapses many changes to the same order into the latest one. Without it, &lt;code&gt;MERGE&lt;/code&gt; would see several source rows matching one target row, which Snowflake treats as nondeterministic and rejects by default.&lt;/li&gt;
&lt;li&gt;Deletes are applied as deletes, not stored as new rows.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;commit_ts &amp;gt;= t.updated_at&lt;/code&gt; guards mean an older event replayed after a restart can't overwrite newer data, which makes the load safe to re-run.&lt;/li&gt;
&lt;li&gt;If two changes to the same key can share a commit timestamp (for example, within one transaction), add a tie-breaker such as a log position or sequence column to the &lt;code&gt;ORDER BY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Track which staged rows have already been merged. A Snowflake stream on the staging table works well for this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: Snowflake runs minutes behind production instead of hours, and the source database barely notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common CDC mistakes
&lt;/h2&gt;

&lt;p&gt;These are the ones that tend to show up in production rather than in the demo.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the initial snapshot.&lt;/strong&gt; CDC only captures changes from the moment it starts. Without a consistent full load taken at a known log position, you're missing all history before that point, and any row that never changes again never shows up. Use a tool mode that does snapshot + stream from the same position (DMS full load and CDC, Debezium's initial snapshot).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating events as append-only.&lt;/strong&gt; If you insert every event as a new row, updates turn into duplicates and deleted rows never go away. Apply changes with a &lt;code&gt;MERGE&lt;/code&gt;, or model deletes explicitly with an &lt;code&gt;is_deleted&lt;/code&gt; flag if you need history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming exactly-once delivery.&lt;/strong&gt; Most CDC pipelines are at-least-once. After a connector restart you can see the same event twice. Make loads idempotent and deduplicate on primary key plus log position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the log itself.&lt;/strong&gt; In PostgreSQL, a replication slot that nobody is consuming keeps holding on to WAL, and the disk keeps filling until the database is in trouble. In MySQL, if binlog retention is shorter than your connector's downtime, the connector loses its place and you need a fresh snapshot. Monitor replication lag and slot size:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="c1"&gt;-- PostgreSQL: how much WAL each slot is holding back&lt;/span&gt;
   &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;restart_lsn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;retained_wal&lt;/span&gt;
   &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On PostgreSQL 13+, &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; puts an upper bound on retained WAL. On RDS for MySQL, set binlog retention with &lt;code&gt;CALL mysql.rds_set_configuration('binlog retention hours', 24);&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bonus: ignoring schema changes.&lt;/strong&gt; A column renamed upstream can quietly break the staging table, the &lt;code&gt;MERGE&lt;/code&gt;, and every dashboard after that. Decide in advance how schema changes get from the source to the warehouse, and alert on them.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When not to use CDC
&lt;/h2&gt;

&lt;p&gt;CDC has more moving parts than a batch job: connectors, log and slot monitoring, and streaming infrastructure that costs money. If a small table changes once a day and a nightly load meets the business need, batch is simpler and cheaper. Pick the freshness the business actually needs, not the most impressive architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CDC captures inserts, updates and deletes as ordered events.&lt;/li&gt;
&lt;li&gt;Log-based CDC reads the database's own transaction log, so it's low impact and it sees deletes.&lt;/li&gt;
&lt;li&gt;Start with a consistent snapshot, apply changes with a &lt;code&gt;MERGE&lt;/code&gt;, make every load idempotent, and keep an eye on your logs and slots.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If this was useful, the video version has the animated diagrams for every step: &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;watch it on YouTube&lt;/a&gt;. &lt;strong&gt;Subscribe to &lt;a href="https://www.youtube.com/@PipelineWhiteboard" rel="noopener noreferrer"&gt;Pipeline Whiteboard&lt;/a&gt;&lt;/strong&gt; for more diagram-first data engineering explainers. Next up: putting Kafka in the middle of this pipeline, and why so many teams do it.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>database</category>
      <category>aws</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
