<?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>Running CDC in Production: The WAL Slot Trap</title>
      <dc:creator>Pipeline Whiteboard</dc:creator>
      <pubDate>Mon, 02 Nov 2026 18:58:15 +0000</pubDate>
      <link>https://dev.to/pipelinewhiteboard/running-cdc-in-production-the-wal-slot-trap-34bj</link>
      <guid>https://dev.to/pipelinewhiteboard/running-cdc-in-production-the-wal-slot-trap-34bj</guid>
      <description>&lt;p&gt;At 03:02 the primary database ran out of disk and Postgres shut down. There was no runaway query. A CDC connector had been paused nine days earlier, and its replication slot had been holding every byte of write-ahead log since.&lt;/p&gt;

&lt;p&gt;This is Part 6 of &lt;strong&gt;CDC in Production&lt;/strong&gt;, the final part. In &lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;Part 5&lt;/a&gt; we shipped schema changes without breaking the warehouse. This post is about keeping the pipeline, and the database under it, alive: the WAL slot trap, heartbeats, the three lag numbers that matter, the alerts and runbook you need, and what all of it costs.&lt;/p&gt;

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

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

&lt;p&gt;Video link: &lt;a href="https://youtu.be/omX2bTWQDFk" rel="noopener noreferrer"&gt;Running CDC in Production: The WAL Slot Trap&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The WAL slot trap
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;write-ahead log (WAL)&lt;/strong&gt; is where Postgres records every change before it counts as committed. A &lt;strong&gt;replication slot&lt;/strong&gt; is a promise about that log. It records the oldest WAL position its consumer still needs, the &lt;code&gt;restart_lsn&lt;/code&gt;, and Postgres keeps every WAL file from that point on.&lt;/p&gt;

&lt;p&gt;If the connector stops, the slot doesn't. Writes keep coming and the retained WAL keeps growing. And by default there's no ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# postgresql.conf
&lt;/span&gt;&lt;span class="py"&gt;max_slot_wal_keep_size&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="s"&gt;-1    # the default: a slot may retain unlimited WAL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the incident. A connector was paused for a migration and nobody resumed it. In this example the database wrote about 40 GB of WAL a day and the disk had 360 GB free, so nine days later it was full. When the disk holding the WAL fills up, the Postgres docs are blunt: the server can panic and shut down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet slot
&lt;/h2&gt;

&lt;p&gt;There's a sneakier version, with the connector running the whole time.&lt;/p&gt;

&lt;p&gt;Debezium confirms its WAL position to Postgres as it commits offsets for the events it sends. Now suppose the captured tables rarely change while the rest of the database is busy. The connector reads the log but sends almost nothing, so the position it confirms falls behind, and Postgres keeps WAL it no longer needs.&lt;/p&gt;

&lt;p&gt;The same happens when you capture a quiet database on a server that also hosts a busy one. Slots are per database, but the WAL is shared by the whole server.&lt;/p&gt;

&lt;p&gt;On Amazon RDS, even an idle instance generates WAL, because RDS writes to its own system tables about every five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heartbeats
&lt;/h2&gt;

&lt;p&gt;The fix is heartbeats. &lt;code&gt;heartbeat.interval.ms&lt;/code&gt; defaults to &lt;code&gt;0&lt;/code&gt;, which means off. Set it, say to ten seconds, and the connector emits a heartbeat on that interval, which gives it the chance to confirm the latest position it has read. Postgres can then recycle the old WAL.&lt;/p&gt;

&lt;p&gt;For the quiet-database case, go one step further and make each heartbeat a real change in the captured database. Create a tiny 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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;cdc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;cdc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heartbeat&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;      &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;beat_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;cdc&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;cdc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heartbeat&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Without this, the connector never sees the heartbeat rows&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;dbz_shop&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;cdc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point &lt;code&gt;heartbeat.action.query&lt;/code&gt; at it, so the connector runs the statement on every beat:&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="nl"&gt;"heartbeat.interval.ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10000"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"heartbeat.action.query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INSERT INTO cdc.heartbeat (id, beat_at) VALUES (1, now()) ON CONFLICT (id) DO UPDATE SET beat_at = EXCLUDED.beat_at"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upsert keeps the table at a single row, so it never grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety valves
&lt;/h2&gt;

&lt;p&gt;Postgres also has safety valves for when everything else fails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;&lt;/strong&gt; caps how much WAL a slot may hold. Past that limit the slot can be marked lost, and the connector can't resume from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;idle_replication_slot_timeout&lt;/code&gt;&lt;/strong&gt; (Postgres 18 and later) invalidates slots that have sat idle for too long.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can watch a slot approach the cap in &lt;code&gt;pg_replication_slots.wal_status&lt;/code&gt;, which moves from &lt;code&gt;reserved&lt;/code&gt; to &lt;code&gt;extended&lt;/code&gt; to &lt;code&gt;unreserved&lt;/code&gt; and finally &lt;code&gt;lost&lt;/code&gt;. The &lt;code&gt;safe_wal_size&lt;/code&gt; column tells you how many more bytes can be written before the slot is in danger.&lt;/p&gt;

&lt;p&gt;Both valves protect the database at the cost of the pipeline, because a lost slot means a fresh snapshot. That's usually the right trade. Just make it on purpose, before the incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of lag
&lt;/h2&gt;

&lt;p&gt;You need all three, because each one lies on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Slot lag, in bytes.&lt;/strong&gt; How much WAL each slot is holding:&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="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;database&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;wal_status&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;wal_held&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;safe_wal_size&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;safe_wal_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;inactive_since&lt;/span&gt;                                &lt;span class="c1"&gt;-- Postgres 17+&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="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;slot_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'logical'&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;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;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On RDS, the CloudWatch metrics &lt;code&gt;ReplicationSlotDiskUsage&lt;/code&gt; and &lt;code&gt;OldestReplicationSlotLag&lt;/code&gt; show the same risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Time lag.&lt;/strong&gt; Debezium's &lt;code&gt;MilliSecondsBehindSource&lt;/code&gt; metric is the gap between a change's commit timestamp and the connector processing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Sink lag.&lt;/strong&gt; Consumer lag on the change topics, plus the age of the newest row in the warehouse (&lt;strong&gt;freshness&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;Why one isn't enough: time lag is measured on events, so when the captured tables go quiet it has nothing new to report, while the slot keeps growing. It also includes any clock difference between the database server and the connector. Slot bytes show the risk to the database. Warehouse freshness shows what your users feel. Put all three on one dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerts, with an owner
&lt;/h2&gt;

&lt;p&gt;Turn those numbers into alerts, and give each alert an owner, a person or rotation that actually gets woken up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Where to read it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;A slot has been inactive for 15 minutes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_replication_slots.active&lt;/code&gt;, &lt;code&gt;inactive_since&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wal_status&lt;/code&gt; is &lt;code&gt;unreserved&lt;/code&gt; or &lt;code&gt;lost&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pg_replication_slots&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;Free disk below your threshold&lt;/td&gt;
&lt;td&gt;Host metrics, or &lt;code&gt;FreeStorageSpace&lt;/code&gt; on RDS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;A connector task is &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Kafka Connect REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warn&lt;/td&gt;
&lt;td&gt;Slot lag high for 30 minutes&lt;/td&gt;
&lt;td&gt;Slot query or RDS metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warn&lt;/td&gt;
&lt;td&gt;Warehouse freshness past your SLA&lt;/td&gt;
&lt;td&gt;Freshness query&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tune the numbers to your system. The shape is what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runbook
&lt;/h2&gt;

&lt;p&gt;When the slot alert fires, run the same four steps every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Look at the slot.&lt;/strong&gt; Run the query above. Is it active? What's its &lt;code&gt;wal_status&lt;/code&gt;? How much WAL is it holding?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Check the connector and restart failed tasks:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://connect:8083/connectors/shop-cdc/status

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'http://connect:8083/connectors/shop-cdc/restart?includeTasks=true&amp;amp;onlyFailed=true'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it comes back healthy, watch slot lag fall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. If the disk will fill before it catches up, make the call.&lt;/strong&gt; Drop the slot to save the database, and plan a fresh snapshot. Stop the connector first, because an active slot can't be dropped:&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="n"&gt;pg_drop_replication_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'debezium_shop'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the connector restarts, its stored position is gone. A snapshot mode such as &lt;code&gt;when_needed&lt;/code&gt; (from Part 2) takes a new snapshot in that case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Afterwards, reconcile.&lt;/strong&gt; Run the bucketed reconciliation from Part 3 to prove nothing is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan the routine events
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Major Postgres upgrades.&lt;/strong&gt; Slots don't survive a major upgrade, and a new slot only sees changes made after it exists. Debezium's documented procedure, in short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stop writes to the database.&lt;/li&gt;
&lt;li&gt;Let the connector drain the slot, then stop it gracefully.&lt;/li&gt;
&lt;li&gt;Drop the slot.&lt;/li&gt;
&lt;li&gt;Upgrade.&lt;/li&gt;
&lt;li&gt;Create the slot again &lt;strong&gt;before writes resume&lt;/strong&gt;, for example with &lt;code&gt;SELECT pg_create_logical_replication_slot('debezium_shop', 'pgoutput');&lt;/code&gt;. Otherwise the changes made in between are lost.&lt;/li&gt;
&lt;li&gt;Check the publication still exists, then restart the connector (the procedure has you recreate it under a new name).&lt;/li&gt;
&lt;li&gt;Resume writes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Failover.&lt;/strong&gt; Since Postgres 17, logical slots can be synchronized to a physical standby. Set &lt;code&gt;slot.failover&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; in Debezium, turn on &lt;code&gt;sync_replication_slots&lt;/code&gt; (with &lt;code&gt;hot_standby_feedback&lt;/code&gt;) on the standby, and list the standby's physical slot in &lt;code&gt;synchronized_standby_slots&lt;/code&gt; on the primary. After a promotion, the connector continues on the new primary. Part 3 covers what happens without this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;A stuck slot doesn't only risk an outage. On RDS with &lt;strong&gt;storage autoscaling&lt;/strong&gt;, the volume grows to absorb the WAL, and it doesn't shrink back afterwards. Getting allocated storage down later means a blue/green deployment or a new instance.&lt;/p&gt;

&lt;p&gt;Elsewhere in the pipeline, Kafka storage grows with retention and replication factor, and in the warehouse every &lt;code&gt;MERGE&lt;/code&gt; uses compute. But the cheapest line item is the slot alert, because it protects the database and the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Leaving a slot behind&lt;/strong&gt; after pausing or deleting a connector.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No heartbeats on a quiet database.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alerting on time lag alone.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;slot.drop.on.stop=true&lt;/code&gt; in production.&lt;/strong&gt; Debezium's docs say it's for testing only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An alert with no owner,&lt;/strong&gt; so the page goes to a channel nobody reads at three in the morning.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;A slot keeps every WAL byte its consumer hasn't confirmed, without limit by default.&lt;/li&gt;
&lt;li&gt;Heartbeats let a quiet connector confirm its position.&lt;/li&gt;
&lt;li&gt;Cap the risk with &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;, and choose that trade on purpose.&lt;/li&gt;
&lt;li&gt;Watch slot bytes, time lag and warehouse freshness together.&lt;/li&gt;
&lt;li&gt;Alert with an owner, and keep a runbook for the slot, upgrades and failover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This week, run one query: list every replication slot, whether it's active, and how much WAL it holds. If one surprises you, you've found your first incident early.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CDC in Production series
&lt;/h2&gt;

&lt;p&gt;That's the end of the series. Here's every part, from the basics to this runbook:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: Change Data Capture (CDC) Explained (&lt;a href="https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582"&gt;article&lt;/a&gt; · &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 2: Debezium Postgres to Kafka: Every Setting Explained (&lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 3: The Silent CDC Bug: Data Loss With No Errors (&lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 4: CDC Deletes, Tombstones and the Idempotent MERGE (&lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Previous, Part 5: CDC Schema Changes: What Breaks and How to Ship Them (&lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 6: Running CDC in Production: The WAL Slot Trap (this post · &lt;a href="https://youtu.be/omX2bTWQDFk" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/strong&gt;&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/omX2bTWQDFk" 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. Thanks for reading the whole series.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>dataengineering</category>
      <category>devops</category>
      <category>aws</category>
    </item>
    <item>
      <title>CDC Schema Changes: What Breaks and How to Ship Them</title>
      <dc:creator>Pipeline Whiteboard</dc:creator>
      <pubDate>Sat, 31 Oct 2026 18:58:27 +0000</pubDate>
      <link>https://dev.to/pipelinewhiteboard/cdc-schema-changes-what-breaks-and-how-to-ship-them-3678</link>
      <guid>https://dev.to/pipelinewhiteboard/cdc-schema-changes-what-breaks-and-how-to-ship-them-3678</guid>
      <description>&lt;p&gt;On Friday at 17:40, someone renamed one column in the &lt;code&gt;orders&lt;/code&gt; table. The pipeline stayed green all weekend. On Monday, the revenue dashboard showed zero for Saturday and Sunday. Nothing failed, and that was the problem.&lt;/p&gt;

&lt;p&gt;This is Part 5 of &lt;strong&gt;CDC in Production&lt;/strong&gt;. In &lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;Part 4&lt;/a&gt; the sink learned to survive duplicates, late events and deletes. This time the table itself changes. We'll follow a schema change through the pipeline, look at the registry rules that decide what's allowed, see what Snowflake and the lakehouse can absorb, and finish with a migration pattern that never breaks either side.&lt;/p&gt;

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

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

&lt;p&gt;Video link: &lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;CDC Schema Changes: What Breaks and How to Ship Them&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a schema change travels
&lt;/h2&gt;

&lt;p&gt;Postgres &lt;strong&gt;logical decoding&lt;/strong&gt; (the feature that turns the write-ahead log into row changes) doesn't carry DDL. So Debezium never sees the &lt;code&gt;ALTER TABLE&lt;/code&gt; itself. It notices the new shape on the &lt;em&gt;next row change&lt;/em&gt; for that table and refreshes its in-memory schema. That event goes out with the new structure.&lt;/p&gt;

&lt;p&gt;If you use Avro, the converter then registers the new structure as a new &lt;strong&gt;schema version&lt;/strong&gt; in &lt;strong&gt;Schema Registry&lt;/strong&gt;, a service that stores every version of each message schema. Versions are grouped under a &lt;strong&gt;subject&lt;/strong&gt;; with the default naming, the value schema for topic &lt;code&gt;shop.public.orders&lt;/code&gt; lives under &lt;code&gt;shop.public.orders-value&lt;/code&gt;. Before accepting a new version, the registry runs a compatibility check against what's already there.&lt;/p&gt;

&lt;p&gt;The converter is configured on the connector (or the Connect worker):&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="nl"&gt;"value.converter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.confluent.connect.avro.AvroConverter"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"value.converter.schema.registry.url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://schema-registry:8081"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Registry compatibility modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Compatibility&lt;/strong&gt; is the rule the registry uses to decide whether a new version may be registered. The default is &lt;code&gt;BACKWARD&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Guarantee&lt;/th&gt;
&lt;th&gt;Upgrade first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BACKWARD&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Consumers using the new schema can read data written with the old one&lt;/td&gt;
&lt;td&gt;Consumers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FORWARD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Consumers using the old schema can read data written with the new one&lt;/td&gt;
&lt;td&gt;Producers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Both directions&lt;/td&gt;
&lt;td&gt;Either side, independently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By default the check compares only against the latest version. The &lt;code&gt;_TRANSITIVE&lt;/code&gt; variants (&lt;code&gt;BACKWARD_TRANSITIVE&lt;/code&gt; and so on) compare against every version, which matters if a consumer might replay old data from the start of a topic.&lt;/p&gt;

&lt;p&gt;What does that allow in practice, with Avro?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;&lt;code&gt;BACKWARD&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;FORWARD&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;FULL&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add an optional field (has a default)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove an optional field&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a required field (no default)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove a required field&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of it comes from one rule in Avro's schema resolution: if the reader's schema has a field with no default, and the data was written without that field, the read fails. With Debezium, a nullable Postgres column becomes an optional field with a &lt;code&gt;null&lt;/code&gt; default. A &lt;code&gt;NOT NULL&lt;/code&gt; column becomes a required field, unless Debezium can carry the column's default value into the schema (it does this for many defaults; for function defaults, see mistake 4 below).&lt;/p&gt;

&lt;h2&gt;
  
  
  The rename, two ways
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;amount_cents&lt;/code&gt; became &lt;code&gt;total_cents&lt;/code&gt;. To Debezium, that's one field gone and a new one added.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the column is &lt;code&gt;NOT NULL&lt;/code&gt;&lt;/strong&gt;, the new field is required, with no default. Under &lt;code&gt;BACKWARD&lt;/code&gt;, the registry rejects it. The converter throws, and with Kafka Connect's default &lt;code&gt;errors.tolerance=none&lt;/code&gt;, the connector task fails. That's loud, and loud is good: someone gets paged on Friday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the real incident, the column was nullable.&lt;/strong&gt; Both fields are optional, so the registry accepts the change and nothing turns red. Snowflake, with schema evolution switched on, adds a &lt;code&gt;TOTAL_CENTS&lt;/code&gt; column. The old &lt;code&gt;AMOUNT_CENTS&lt;/code&gt; column gets &lt;code&gt;NULL&lt;/code&gt; on every new row. Every dashboard still sums &lt;code&gt;AMOUNT_CENTS&lt;/code&gt;, so weekend revenue reads zero.&lt;/p&gt;

&lt;p&gt;A failed connector is the good outcome. The dangerous schema changes are the ones every system accepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Snowflake absorbs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Schema evolution&lt;/strong&gt; lets a load add columns to the target table automatically. It needs three things:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&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;SET&lt;/span&gt; &lt;span class="n"&gt;ENABLE_SCHEMA_EVOLUTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- The loading role needs EVOLVE SCHEMA (or OWNERSHIP) on the table&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;EVOLVE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&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;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a supported loading method: &lt;code&gt;COPY INTO&lt;/code&gt; (with &lt;code&gt;MATCH_BY_COLUMN_NAME&lt;/code&gt;), Snowpipe, or Snowpipe Streaming. Plain &lt;code&gt;INSERT&lt;/code&gt; can't evolve a table.&lt;/p&gt;

&lt;p&gt;And it does only two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it &lt;strong&gt;adds&lt;/strong&gt; new columns, and&lt;/li&gt;
&lt;li&gt;it &lt;strong&gt;drops &lt;code&gt;NOT NULL&lt;/code&gt;&lt;/strong&gt; from columns that are missing in new data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It never renames and never changes a type.&lt;/p&gt;

&lt;p&gt;Type changes through &lt;code&gt;ALTER TABLE ... ALTER COLUMN&lt;/code&gt; are stricter still:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Allowed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Make a &lt;code&gt;VARCHAR&lt;/code&gt; longer&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raise a number's precision&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change a number's scale&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make a &lt;code&gt;VARCHAR&lt;/code&gt; shorter&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn a number into a &lt;code&gt;VARCHAR&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So if Postgres moves &lt;code&gt;price&lt;/code&gt; from &lt;code&gt;numeric(10,2)&lt;/code&gt; to &lt;code&gt;numeric(12,4)&lt;/code&gt;, Snowflake can't follow in place. The scale changed. You add a new column, backfill it, and switch readers over, which is the pattern at the end of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lakehouse: more forgiving, not magic
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Apache Iceberg&lt;/strong&gt; tracks every column by a unique field ID, not by its name. Renames and drops are metadata changes, so no data files are rewritten. A new column gets a fresh ID, so it never reads old data by mistake, even if it reuses an old name. Type changes are limited to safe widening: &lt;code&gt;int&lt;/code&gt; to &lt;code&gt;long&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt; to &lt;code&gt;double&lt;/code&gt;, or a &lt;code&gt;decimal&lt;/code&gt; with more precision and the same scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delta Lake&lt;/strong&gt; can rename and drop columns only with &lt;strong&gt;column mapping&lt;/strong&gt; enabled:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TBLPROPERTIES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.columnMapping.mode'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.minReaderVersion'&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.minWriterVersion'&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a protocol upgrade you can't undo, and after a rename or drop it can break readers of the table's change data feed.&lt;/p&gt;

&lt;p&gt;Here's the catch for both formats: a CDC sink still sees a rename as a dropped field and a new one, because that's what the events say. Unless you rename the table column yourself, you get the same split as in Snowflake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand, then contract
&lt;/h2&gt;

&lt;p&gt;The safe pattern is &lt;strong&gt;expand/contract&lt;/strong&gt;: make the schema bigger first, move everything over, and only then make it smaller. A rename becomes four steps:&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="c1"&gt;-- 1. Expand: add the new column as nullable (or with a constant default)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Backfill in small batches, and have the application write both columns&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Move every reader (views, models, dashboards) to total_cents&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Contract, weeks later, once nothing reads the old column&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;amount_cents&lt;/code&gt; was nullable, every step only adds or removes an optional field, which the registry accepts in any mode. (Dropping a &lt;code&gt;NOT NULL&lt;/code&gt; column removes a required field, which passes &lt;code&gt;BACKWARD&lt;/code&gt; but not &lt;code&gt;FORWARD&lt;/code&gt; or &lt;code&gt;FULL&lt;/code&gt;.) Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The backfill is CDC traffic.&lt;/strong&gt; Every row it updates becomes a change event, which is why it runs in batches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake won't drop the old column for you.&lt;/strong&gt; Schema evolution never removes columns. Drop &lt;code&gt;AMOUNT_CENTS&lt;/code&gt; in the warehouse yourself once readers have moved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two guardrails
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test schemas in CI.&lt;/strong&gt; Schema Registry has a compatibility endpoint that tells you whether a schema would be accepted, without registering it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wrap the new Avro schema in the request body the registry expects&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json, sys; print(json.dumps({"schema": open(sys.argv[1]).read()}))'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  orders-value.avsc &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; payload.json

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/vnd.schemaregistry.v1+json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; @payload.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'http://schema-registry:8081/compatibility/subjects/shop.public.orders-value/versions/latest?verbose=true'&lt;/span&gt;
&lt;span class="c"&gt;# {"is_compatible":false,"messages":[...]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail the build when &lt;code&gt;is_compatible&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. Remember that a nullable rename passes this check, so it catches only the loud kind of break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the warehouse.&lt;/strong&gt; Alert when a new column appears on a captured table, and when a column's null rate jumps:&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="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&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;AS&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;rows_loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COUNT_IF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;null_rate&lt;/span&gt;
&lt;span class="k"&gt;FROM&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;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="n"&gt;DATEADD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="n"&gt;null_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold is yours to pick. Either alert would have caught Friday's rename on Friday night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Renaming a column with a single &lt;code&gt;ALTER&lt;/code&gt;.&lt;/strong&gt; Use expand/contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing a numeric column's scale in place.&lt;/strong&gt; Snowflake can't follow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting compatibility to &lt;code&gt;NONE&lt;/code&gt;&lt;/strong&gt; just to get a failed connector running again. You've switched off the only alarm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting function defaults.&lt;/strong&gt; Debezium exports a default like &lt;code&gt;CURRENT_TIMESTAMP(6)&lt;/code&gt; as a zero value (&lt;code&gt;1970-01-01&lt;/code&gt;) in the schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing a schema during an incremental snapshot.&lt;/strong&gt; The Postgres connector doesn't support it.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Postgres doesn't send DDL, so the next event carries the new schema.&lt;/li&gt;
&lt;li&gt;The registry checks compatibility, &lt;code&gt;BACKWARD&lt;/code&gt; by default. Optional fields are safe to add or remove; required fields are not.&lt;/li&gt;
&lt;li&gt;Snowflake adds columns, but never renames or changes types.&lt;/li&gt;
&lt;li&gt;In the lakehouse, your sink still sees a rename as a drop and an add.&lt;/li&gt;
&lt;li&gt;So expand, then contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This week, find the last schema change on a captured table and check what it did in the warehouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CDC in Production series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: Change Data Capture (CDC) Explained (&lt;a href="https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582"&gt;article&lt;/a&gt; · &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 2: Debezium Postgres to Kafka: Every Setting Explained (&lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 3: The Silent CDC Bug: Data Loss With No Errors (&lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Previous, Part 4: CDC Deletes, Tombstones and the Idempotent MERGE (&lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 5: CDC Schema Changes: What Breaks and How to Ship Them (this post · &lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Next, Part 6: Running CDC in Production: The WAL Slot Trap (&lt;a href="https://youtu.be/omX2bTWQDFk" rel="noopener noreferrer"&gt;video&lt;/a&gt;, out November 2)&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/uhYmKOktRZ8" 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, in the final part: the WAL slot trap, lag, heartbeats, alerting and runbooks.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>kafka</category>
      <category>snowflake</category>
      <category>database</category>
    </item>
    <item>
      <title>CDC Deletes, Tombstones and the Idempotent MERGE</title>
      <dc:creator>Pipeline Whiteboard</dc:creator>
      <pubDate>Fri, 30 Oct 2026 18:32:10 +0000</pubDate>
      <link>https://dev.to/pipelinewhiteboard/cdc-deletes-tombstones-and-the-idempotent-merge-3cde</link>
      <guid>https://dev.to/pipelinewhiteboard/cdc-deletes-tombstones-and-the-idempotent-merge-3cde</guid>
      <description>&lt;p&gt;Order 1042 was deleted at 14:10. At 15:00 it's back in the warehouse, marked &lt;code&gt;shipped&lt;/code&gt;. Nobody re-created it. A retried batch did.&lt;/p&gt;

&lt;p&gt;This is Part 4 of &lt;strong&gt;CDC in Production&lt;/strong&gt;. In &lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; we caught silent data loss and repaired it by re-sending changes. Crashes re-send changes too. So this post covers the order CDC events really arrive in, how deletes and tombstones work, and a Snowflake &lt;code&gt;MERGE&lt;/code&gt; that gets the right answer even when an event shows up twice, or late.&lt;/p&gt;

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

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

&lt;p&gt;Video link: &lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;CDC Deletes, Tombstones and the Idempotent MERGE&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The ordering promise, and its fine print
&lt;/h2&gt;

&lt;p&gt;Start with what is actually guaranteed. Debezium uses the row's primary key as the Kafka &lt;strong&gt;message key&lt;/strong&gt;. Kafka writes every message with the same key to the same &lt;strong&gt;partition&lt;/strong&gt; (one ordered slice of a topic), and a partition is read in exactly the order it was written. So changes to one row arrive in order. Changes to different rows, on different partitions, have no order between them at all.&lt;/p&gt;

&lt;p&gt;Two pieces of fine print:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adding partitions breaks the mapping.&lt;/strong&gt; The partition for a key is derived from the key and the partition count. Add partitions to a live topic and new events for a row can land on a different partition from its old ones, so a consumer can read them out of order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery is at least once.&lt;/strong&gt; After a crash, Debezium can re-send events it already sent, because its stored position is only committed every so often. Your sink must expect duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the sink rule is: expect duplicates, and never assume an order across keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every change already has a version
&lt;/h2&gt;

&lt;p&gt;To cope with duplicates and late events, each change needs a version number that only goes up. You already have one. Every Debezium event has a &lt;code&gt;source&lt;/code&gt; block, and on Postgres it carries &lt;code&gt;lsn&lt;/code&gt;, the &lt;strong&gt;log sequence number&lt;/strong&gt;: the position of the change in the write-ahead log (WAL). On Oracle the equivalent is &lt;code&gt;scn&lt;/code&gt;, the system change number.&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="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;"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;"orders"&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;88240&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;3456121520&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;p&gt;Row locks mean only one transaction can change a given row at a time, so for a single row a later change always has a higher position. That's exactly the property we need. A timestamp doesn't have it: many changes can share the same millisecond.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deletes and tombstones
&lt;/h2&gt;

&lt;p&gt;A delete event has &lt;code&gt;op&lt;/code&gt; set to &lt;code&gt;d&lt;/code&gt;, the old row in &lt;code&gt;before&lt;/code&gt;, and &lt;code&gt;after&lt;/code&gt; set to &lt;code&gt;null&lt;/code&gt;. With the default replica identity (see Part 2), &lt;code&gt;before&lt;/code&gt; holds only the primary key:&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;"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;"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;1042&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="kc"&gt;null&lt;/span&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;"d"&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;p&gt;Then, by default (&lt;code&gt;tombstones.on.delete&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;), Debezium sends a second message: a &lt;strong&gt;tombstone&lt;/strong&gt;, with the same key and a &lt;code&gt;null&lt;/code&gt; value. It exists for &lt;strong&gt;log compaction&lt;/strong&gt;, the Kafka cleanup mode (&lt;code&gt;cleanup.policy=compact&lt;/code&gt;) that keeps only the latest message per key. A tombstone tells compaction it may eventually drop every message for that key.&lt;/p&gt;

&lt;p&gt;Three things trip people up here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tombstones expire.&lt;/strong&gt; On a compacted topic, &lt;code&gt;delete.retention.ms&lt;/code&gt; keeps them for one day by default (&lt;code&gt;86400000&lt;/code&gt;), then they're removed. A consumer that is more than a day behind can miss the delete entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Debezium JDBC sink ignores deletes by default.&lt;/strong&gt; &lt;code&gt;delete.enabled&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. Turning it on also requires &lt;code&gt;primary.key.mode=record_key&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flattening can drop them.&lt;/strong&gt; Most warehouse loaders want flat rows, not the &lt;code&gt;before&lt;/code&gt;/&lt;code&gt;after&lt;/code&gt; envelope, so they use the &lt;code&gt;ExtractNewRecordState&lt;/code&gt; transform. Its &lt;code&gt;delete.tombstone.handling.mode&lt;/code&gt; defaults to &lt;code&gt;tombstone&lt;/code&gt;: the delete event is dropped and only the tombstone is kept. A loader that skips null values then never sees the delete.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Set the mode to &lt;code&gt;rewrite&lt;/code&gt; and you get a flat row instead, with a &lt;code&gt;__deleted&lt;/code&gt; field. Add the log position and commit time while you're there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;unwrap&lt;/span&gt;
&lt;span class="py"&gt;transforms.unwrap.type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;io.debezium.transforms.ExtractNewRecordState&lt;/span&gt;
&lt;span class="py"&gt;transforms.unwrap.delete.tombstone.handling.mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;rewrite&lt;/span&gt;
&lt;span class="py"&gt;transforms.unwrap.add.fields&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;op,lsn,source.ts_ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A delete of order 1042 now arrives as an ordinary record:&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;"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;1042&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"__deleted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&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;"d"&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="s2"&gt;"3456121520"&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_ts_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1792591800000"&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;p&gt;The other columns are &lt;code&gt;null&lt;/code&gt; because, with the default replica identity, the delete only carried the key. &lt;code&gt;rewrite&lt;/code&gt; also removes the tombstones; if you still need them for compaction, there's a &lt;code&gt;rewrite-with-tombstone&lt;/code&gt; mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Primary key changes are two rows
&lt;/h2&gt;

&lt;p&gt;One more event shape catches people out. If an &lt;code&gt;UPDATE&lt;/code&gt; changes the primary key (say &lt;code&gt;id&lt;/code&gt; 1042 becomes 2042), Debezium doesn't send an update. It sends a delete and a tombstone for the old key, then a create for the new key. The delete carries a &lt;code&gt;__debezium.newkey&lt;/code&gt; header and the create carries &lt;code&gt;__debezium.oldkey&lt;/code&gt;, linking the two.&lt;/p&gt;

&lt;p&gt;Those are different keys, so they can land on different partitions and nothing orders them. Your sink must treat them as two independent rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the zombie row happened
&lt;/h2&gt;

&lt;p&gt;Back to order 1042. The loader writes &lt;strong&gt;micro-batches&lt;/strong&gt; (small groups of events loaded every few minutes) into Snowflake:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Batch 7 holds the update to &lt;code&gt;shipped&lt;/code&gt;. It times out.&lt;/li&gt;
&lt;li&gt;Batch 8 holds the delete. It succeeds, and the row is gone.&lt;/li&gt;
&lt;li&gt;The loader retries batch 7. The &lt;code&gt;MERGE&lt;/code&gt; finds no row with that &lt;code&gt;id&lt;/code&gt;, so it inserts one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The deleted order is back, and the counts even look plausible. Two design choices made it possible: the warehouse forgot the delete, and it applied events without checking whether they were newer than what it already had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: soft deletes plus "only newer wins"
&lt;/h2&gt;

&lt;p&gt;The fix has two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never forget a delete.&lt;/strong&gt; Keep it as a &lt;strong&gt;soft-deleted&lt;/strong&gt; row: &lt;code&gt;is_deleted = TRUE&lt;/code&gt;, plus the position it happened at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only apply an event if its position is higher&lt;/strong&gt; than the one the row already holds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the retried update from batch 7 (at &lt;code&gt;lsn&lt;/code&gt; 500, say) arrives after the delete (at &lt;code&gt;lsn&lt;/code&gt; 520). Is 500 greater than 520? No, so it's ignored.&lt;/p&gt;

&lt;p&gt;The target table needs two extra columns, and a third if you want to purge by age later:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;NUMBER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="n"&gt;NUMBER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;is_deleted&lt;/span&gt;   &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lsn&lt;/span&gt;          &lt;span class="n"&gt;NUMBER&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;changed_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMP_NTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each batch lands in a staging table, &lt;code&gt;orders_changes&lt;/code&gt;, with the flattened fields mapped to columns: &lt;code&gt;is_deleted&lt;/code&gt; from &lt;code&gt;__deleted = 'true'&lt;/code&gt;, &lt;code&gt;lsn&lt;/code&gt; from &lt;code&gt;__lsn&lt;/code&gt; cast to a number, and &lt;code&gt;changed_at&lt;/code&gt; from &lt;code&gt;TO_TIMESTAMP_NTZ(__source_ts_ms, 3)&lt;/code&gt; (the &lt;code&gt;3&lt;/code&gt; means the value is in milliseconds).&lt;/p&gt;

&lt;h2&gt;
  
  
  The idempotent MERGE
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Idempotent&lt;/strong&gt; means applying the same input again leaves the result unchanged. Here's the &lt;code&gt;MERGE&lt;/code&gt; that gets you there:&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;orders&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_changes&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;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;lsn&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="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;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;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;lsn&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;lsn&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;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;amount_cents&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_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;is_deleted&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;is_deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lsn&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;lsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;changed_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;changed_at&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;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;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_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;changed_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;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_cents&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;is_deleted&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;lsn&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;changed_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate the batch.&lt;/strong&gt; &lt;code&gt;QUALIFY&lt;/code&gt; filters on a window function, keeping only the latest event per &lt;code&gt;id&lt;/code&gt; (highest &lt;code&gt;lsn&lt;/code&gt;). This isn't optional in Snowflake: by default, several source rows matching one target row makes an updating &lt;code&gt;MERGE&lt;/code&gt; fail with a nondeterministic-merge error (&lt;code&gt;ERROR_ON_NONDETERMINISTIC_MERGE&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Join on the key.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When matched, only newer wins.&lt;/strong&gt; The update runs only if &lt;code&gt;s.lsn &amp;gt; t.lsn&lt;/code&gt;, and it sets the deleted flag too, so a delete becomes a soft delete and a later re-insert of the same key revives the row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When not matched, insert, even a delete.&lt;/strong&gt; That way the delete's position is remembered, and a late update for the same row loses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you'd rather keep the last known values on deleted rows instead of the &lt;code&gt;null&lt;/code&gt;s a delete brings, use &lt;code&gt;status = IFF(s.is_deleted, t.status, s.status)&lt;/code&gt; and the same for other columns.&lt;/p&gt;

&lt;p&gt;Why this is safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same batch twice:&lt;/strong&gt; every &lt;code&gt;lsn&lt;/code&gt; is equal, not higher, so nothing changes the second time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batches out of order:&lt;/strong&gt; older events lose to newer ones already applied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-snapshots from Part 3:&lt;/strong&gt; read events go through the same check, so they can't overwrite a row the warehouse already holds at a newer position.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can still hard delete, as a separate job that only removes soft-deleted rows older than any replay you'd ever run:&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;DELETE&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;is_deleted&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;changed_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;DATEADD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="n"&gt;TIMESTAMP_NTZ&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the window to be longer than your Kafka retention and your longest possible backfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it on purpose
&lt;/h2&gt;

&lt;p&gt;Don't trust the &lt;code&gt;MERGE&lt;/code&gt; until you've tried to break it. Take one batch, shuffle it, apply it twice, then apply an older batch on top. The final table must equal one clean, in-order pass. With both results in tables, the comparison is two set differences:&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="p"&gt;(&lt;/span&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_test&lt;/span&gt;  &lt;span class="n"&gt;MINUS&lt;/span&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_clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&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_clean&lt;/span&gt; &lt;span class="n"&gt;MINUS&lt;/span&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_test&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero rows means they match. Any rows means you have an ordering bug, and it's better you find it than production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ordering by a timestamp.&lt;/strong&gt; Many changes share the same millisecond. Use the log position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard deletes in the sink.&lt;/strong&gt; The position is forgotten, and late events bring rows back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving the JDBC sink's &lt;code&gt;delete.enabled&lt;/code&gt; at &lt;code&gt;false&lt;/code&gt;.&lt;/strong&gt; Deleted rows live forever downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding partitions to a live change topic.&lt;/strong&gt; Per-key order breaks for existing keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capturing a table with no primary key.&lt;/strong&gt; With the default replica identity, Debezium emits only creates for it, no updates or deletes. Give it a key, or change its replica identity, before you capture it.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Order is per key, and only while the partition count stays put. Delivery is at least once.&lt;/li&gt;
&lt;li&gt;Deletes arrive as a delete event plus a tombstone, and tombstones expire.&lt;/li&gt;
&lt;li&gt;Version every row by its log position and keep deletes as soft deletes.&lt;/li&gt;
&lt;li&gt;Deduplicate each batch, then apply only newer events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run one test this week, replay yesterday's batches into a copy of the table, shuffled and twice, and compare it with the real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CDC in Production series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: Change Data Capture (CDC) Explained (&lt;a href="https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582"&gt;article&lt;/a&gt; Â· &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 2: Debezium Postgres to Kafka: Every Setting Explained (&lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Previous, Part 3: The Silent CDC Bug: Data Loss With No Errors (&lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 4: CDC Deletes, Tombstones and the Idempotent MERGE (this post Â· &lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Next, Part 5: CDC Schema Changes: What Breaks and How to Ship Them (&lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;video&lt;/a&gt;, out October 31)&lt;/li&gt;
&lt;li&gt;Coming up: Part 6 (the WAL slot trap)&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/5FUEvlc_npc" 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, in Part 5: schema changes, registry compatibility, and what breaks in Snowflake and the lakehouse.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>kafka</category>
      <category>snowflake</category>
      <category>sql</category>
    </item>
    <item>
      <title>The Silent CDC Bug: Data Loss With No Errors</title>
      <dc:creator>Pipeline Whiteboard</dc:creator>
      <pubDate>Tue, 27 Oct 2026 16:21:38 +0000</pubDate>
      <link>https://dev.to/pipelinewhiteboard/the-silent-cdc-bug-data-loss-with-no-errors-5ahh</link>
      <guid>https://dev.to/pipelinewhiteboard/the-silent-cdc-bug-data-loss-with-no-errors-5ahh</guid>
      <description>&lt;p&gt;Every dashboard is green. The connector says &lt;code&gt;RUNNING&lt;/code&gt;, capture lag is under a second, and the warehouse is missing 4,812 orders. Nobody gets paged, because nothing failed.&lt;/p&gt;

&lt;p&gt;This is Part 3 of &lt;strong&gt;CDC in Production&lt;/strong&gt;. In &lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; we built a Debezium pipeline from Postgres to Kafka. This post covers four ways that pipeline can lose data without a single error, and the reconciliation check that catches all of them.&lt;/p&gt;

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

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

&lt;p&gt;Video link: &lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;The Silent CDC Bug: Data Loss With No Errors&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing alerts
&lt;/h2&gt;

&lt;p&gt;The usual signals measure &lt;em&gt;flow&lt;/em&gt;, not &lt;em&gt;completeness&lt;/em&gt;. Task status tells you the connector is alive. Lag tells you how far behind the newest event is. Neither tells you whether every change made it. A pipeline that skipped a thousand rows an hour ago looks exactly like a healthy one now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: the slot moves on after a failover
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;replication slot&lt;/strong&gt; is Postgres's bookmark for a logical decoding reader: it remembers the last position the reader confirmed. Here's how it goes wrong.&lt;/p&gt;

&lt;p&gt;The primary dies at 14:00 and a standby is promoted. On self-managed Postgres 16 or earlier, logical slots don't follow a promotion. So someone recreates the slot on the new primary and the connector reconnects. A new slot starts at the &lt;em&gt;current&lt;/em&gt; position in the log, so everything written between the promotion and that moment is never decoded.&lt;/p&gt;

&lt;p&gt;Why no error? The connector's stored offset is now behind the slot. Debezium's &lt;code&gt;offset.mismatch.strategy&lt;/code&gt; defaults to &lt;code&gt;no_validation&lt;/code&gt;, and on Postgres 15 and newer the server streams from the slot's confirmed position. The gap is skipped and the connector reports &lt;code&gt;RUNNING&lt;/code&gt; throughout.&lt;/p&gt;

&lt;p&gt;Defences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postgres 17+: failover slots.&lt;/strong&gt; Set &lt;code&gt;slot.failover&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; in the connector so it creates a failover-enabled slot, and keep it in sync on the standby:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# primary: postgresql.conf
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="py"&gt;synchronized_standby_slots&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="s"&gt;'standby1_slot'&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# the standby's physical slot
&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# standby: postgresql.conf
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="py"&gt;sync_replication_slots&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="s"&gt;on&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="py"&gt;hot_standby_feedback&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="s"&gt;on&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="py"&gt;primary_slot_name&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="s"&gt;'standby1_slot'&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# primary_conninfo must include a dbname for slot sync to work
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed services.&lt;/strong&gt; Debezium's docs note that some managed services, Amazon RDS among them, keep the slot through a failover because they replicate the storage. Check what yours does &lt;em&gt;before&lt;/em&gt; you need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail loudly.&lt;/strong&gt; &lt;code&gt;"offset.mismatch.strategy": "trust_offset"&lt;/code&gt; makes the connector stop instead of silently skipping. It's a technology preview in current Debezium, so test it first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failure 2: the table that was never captured
&lt;/h2&gt;

&lt;p&gt;Someone adds &lt;code&gt;public.refunds&lt;/code&gt; to &lt;code&gt;table.include.list&lt;/code&gt; and restarts the connector. But the publication was created by hand (as in Part 2), and nobody added &lt;code&gt;refunds&lt;/code&gt; to it. Postgres only decodes tables in the publication, so no refund events ever arrive. The connector is healthy; the topic is just empty.&lt;/p&gt;

&lt;p&gt;Fix the publication first:&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;ALTER&lt;/span&gt; &lt;span class="n"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;dbz_shop&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunds&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then there's a second trap: adding a table only captures changes &lt;em&gt;from that point on&lt;/em&gt;. Existing rows are never sent unless you ask. Debezium's docs suggest an &lt;strong&gt;incremental snapshot&lt;/strong&gt; (a chunked copy that runs alongside streaming) after you add a table. You trigger one through a &lt;strong&gt;signal table&lt;/strong&gt;, a small table Debezium watches for commands:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debezium_signal&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;   &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debezium_signal&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;dbz_shop&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debezium_signal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the connector at it with &lt;code&gt;"signal.data.collection": "public.debezium_signal"&lt;/code&gt;, then send the request:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debezium_signal&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;data&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="s1"&gt;'add-refunds'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute-snapshot'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'{"data-collections": ["public.refunds"], "type": "incremental"}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until you do, the warehouse has every new refund and none of the old ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: the TOAST placeholder
&lt;/h2&gt;

&lt;p&gt;Postgres stores large values out of line, in what it calls &lt;strong&gt;TOAST&lt;/strong&gt; storage. When an update doesn't change a TOASTed value, that value isn't included in the WAL message, unless the table uses &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt;. Debezium can't safely fill it in, so it sends a placeholder: &lt;code&gt;__debezium_unavailable_value&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now picture a sink that writes the &lt;code&gt;after&lt;/code&gt; image straight into the table. Order 1042 has long delivery notes. Someone changes only the status. The event arrives as:&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;"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;1042&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shipped"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"__debezium_unavailable_value"&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;p&gt;and the sink overwrites the real notes with the placeholder. Row counts still match; the data is quietly wrong.&lt;/p&gt;

&lt;p&gt;Two fixes. Set &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt; on tables with large columns you need downstream. Or make the sink keep the old value when it sees the placeholder, for example in a Snowflake &lt;code&gt;MERGE&lt;/code&gt;:&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;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&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;notes&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;IFF&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;notes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'__debezium_unavailable_value'&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;notes&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;notes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Failure 4: dropped downstream
&lt;/h2&gt;

&lt;p&gt;This one happens after Kafka. In Kafka Connect, &lt;code&gt;errors.tolerance&lt;/code&gt; defaults to &lt;code&gt;none&lt;/code&gt;, so any bad record stops the task. Teams flip it to &lt;code&gt;all&lt;/code&gt; to stop the pages. Now records that fail conversion or transformation are skipped. Without a &lt;strong&gt;dead letter queue&lt;/strong&gt; (DLQ, a topic that collects failed records), the only trace is a log line, and only if error logging is on. If you tolerate errors in a sink connector, at least keep them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;errors.tolerance&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;
&lt;span class="py"&gt;errors.log.enable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;errors.log.include.messages&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;errors.deadletterqueue.topic.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;dlq.shop.orders&lt;/span&gt;
&lt;span class="py"&gt;errors.deadletterqueue.context.headers.enable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The DLQ settings apply to sink connectors.) Then alert when the DLQ topic receives anything.&lt;/p&gt;

&lt;p&gt;The other downstream gap is &lt;strong&gt;retention&lt;/strong&gt;. A topic keeps messages for seven days by default (&lt;code&gt;retention.ms=604800000&lt;/code&gt;). If the sink is down longer than that, the oldest events are deleted before it reads them. When it comes back, its committed offset no longer exists, so a Connect sink resets to the earliest message that's left and carries on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconciliation: compare the data itself
&lt;/h2&gt;

&lt;p&gt;All four failures share one property: the only reliable way to catch them is to compare the data. &lt;strong&gt;Reconciliation&lt;/strong&gt; means running the same query on the source and on the warehouse and comparing the answers.&lt;/p&gt;

&lt;p&gt;Not one grand total, though. Split the table into &lt;strong&gt;buckets&lt;/strong&gt;, say one per hour of &lt;code&gt;updated_at&lt;/code&gt;, so a mismatch tells you where to look. For each bucket compute three numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Row count&lt;/strong&gt; catches missing rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sum&lt;/strong&gt; of a numeric column like &lt;code&gt;amount_cents&lt;/code&gt; catches wrong amounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checksum&lt;/strong&gt; catches everything else: hash each row's key and values, turn the hash into a number, and add them up. Addition doesn't care about row order, so both systems can agree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Postgres side:&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;SET&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&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;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&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;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;row_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&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;amount_sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;concat_ws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;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_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;null&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&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;checksum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="k"&gt;public&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;WHERE&lt;/span&gt;  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-10-21 15:00:00+00'&lt;/span&gt;   &lt;span class="c1"&gt;-- the watermark&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Snowflake side builds the identical string and hash. &lt;code&gt;TO_NUMBER&lt;/code&gt; with an &lt;code&gt;X&lt;/code&gt; format parses hex:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TIMEZONE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&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;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&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;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;row_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&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;amount_sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TO_NUMBER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MD5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;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_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;null&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="s1"&gt;'XXXXXXXXXXXXXXX'&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;checksum&lt;/span&gt;
&lt;span class="k"&gt;FROM&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;WHERE&lt;/span&gt;  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-10-21 15:00:00'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If the warehouse keeps soft-deleted rows, as in Part 4, add &lt;code&gt;AND NOT is_deleted&lt;/code&gt; so they don't count as extra rows.)&lt;/p&gt;

&lt;p&gt;Look at the &lt;code&gt;WHERE&lt;/code&gt; clause. That's the &lt;strong&gt;watermark&lt;/strong&gt;: only rows last updated before a cutoff (here, at least 30 minutes ago, rounded down to the hour) are compared, because anything newer may still be in flight. Compute the cutoff once and pass the same value to both queries.&lt;/p&gt;

&lt;p&gt;Load both result sets into one place and compare bucket by bucket:&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="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&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;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;row_count&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;src_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;row_count&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;wh_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;checksum&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;src_sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;checksum&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;wh_sum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;recon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_buckets&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;FULL&lt;/span&gt; &lt;span class="k"&gt;OUTER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;recon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wh_buckets&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;row_count&lt;/span&gt;  &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;row_count&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_sum&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_sum&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;checksum&lt;/span&gt;   &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;checksum&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the video's example, the 14:00 bucket is short by 4,812 rows: that's the failover window. A later bucket has matching counts but a different checksum, so the values are wrong, not missing (hello, TOAST placeholder). A row changed during the check can cause a false alarm, so re-check a mismatched bucket before alerting. Real loss stays; in-flight changes go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repair: re-snapshot only the bad bucket
&lt;/h2&gt;

&lt;p&gt;Repair is targeted. The signal table accepts an &lt;code&gt;execute-snapshot&lt;/code&gt; request with &lt;code&gt;additional-conditions&lt;/code&gt;, so you re-snapshot only the broken bucket, not the whole 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="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debezium_signal&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;data&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="s1"&gt;'repair-orders-1400'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute-snapshot'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'{"data-collections": ["public.orders"],
    "type": "incremental",
    "additional-conditions": [{
      "data-collection": "public.orders",
      "filter": "updated_at &amp;gt;= &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;2026-10-21 14:00+00&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; AND updated_at &amp;lt; &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;2026-10-21 15:00+00&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;"
    }]}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rows arrive as read events (&lt;code&gt;op&lt;/code&gt; = &lt;code&gt;r&lt;/code&gt;) while streaming carries on. Your sink has to apply them safely on top of what's already there, which is exactly what Part 4 is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five reconciliation mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Treating &lt;code&gt;RUNNING&lt;/code&gt; and low lag as proof nothing is missing.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciling one total count.&lt;/strong&gt; A lost insert and a lost delete cancel out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No watermark.&lt;/strong&gt; Comparing live data produces false alarms and teaches everyone to ignore the alert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashing differently on each side.&lt;/strong&gt; Timestamps, decimals and nulls must become exactly the same text in both systems before hashing. (Note that Snowflake's &lt;code&gt;CONCAT_WS&lt;/code&gt; returns &lt;code&gt;NULL&lt;/code&gt; if any argument is &lt;code&gt;NULL&lt;/code&gt;, while Postgres skips nulls. Hence the &lt;code&gt;COALESCE&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming a failover keeps your slot.&lt;/strong&gt; Check your Postgres version and provider before the failover, not after.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Silent loss comes from slots that move on, tables never captured, placeholders that overwrite real values, and records dropped downstream.&lt;/li&gt;
&lt;li&gt;Status and lag can't see any of it.&lt;/li&gt;
&lt;li&gt;Bucketed counts, sums and checksums below a watermark can.&lt;/li&gt;
&lt;li&gt;A targeted incremental snapshot repairs what they find.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you add one check this week, make it an hourly row count on your most important table, source versus warehouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CDC in Production series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: Change Data Capture (CDC) Explained (&lt;a href="https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582"&gt;article&lt;/a&gt; · &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Previous, Part 2: Debezium Postgres to Kafka: Every Setting Explained (&lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 3: The Silent CDC Bug (this post · &lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Next, Part 4: CDC Deletes, Tombstones and the Idempotent MERGE (&lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;video&lt;/a&gt;, out October 29)&lt;/li&gt;
&lt;li&gt;Coming up: Part 5 (schema changes), Part 6 (the WAL slot trap)&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/YDuH1HnyCU0" 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, in Part 4: ordering, deletes and tombstones, and a MERGE that applies every change correctly even when it arrives twice.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>postgres</category>
      <category>kafka</category>
      <category>database</category>
    </item>
    <item>
      <title>Debezium Postgres to Kafka: Every Setting Explained</title>
      <dc:creator>Pipeline Whiteboard</dc:creator>
      <pubDate>Tue, 27 Oct 2026 16:18:51 +0000</pubDate>
      <link>https://dev.to/pipelinewhiteboard/debezium-postgres-to-kafka-every-setting-explained-1fen</link>
      <guid>https://dev.to/pipelinewhiteboard/debezium-postgres-to-kafka-every-setting-explained-1fen</guid>
      <description>&lt;p&gt;About fifteen lines of JSON. That's all it takes to stream every insert, update and delete from Postgres into Kafka with Debezium. And two of the defaults hiding in those lines will hurt you in production if you leave them alone.&lt;/p&gt;

&lt;p&gt;This is Part 2 of &lt;strong&gt;CDC in Production&lt;/strong&gt;. &lt;a href="https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582"&gt;Part 1&lt;/a&gt; explained what change data capture (CDC) is. This post opens up the most common open-source setup, Debezium in Kafka Connect reading Postgres: what to prepare in the database, what every setting does, what happens on first start, and what lands in each Kafka message.&lt;/p&gt;

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

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

&lt;p&gt;Video link: &lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;Debezium Postgres to Kafka: Every Setting Explained&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The moving parts
&lt;/h2&gt;

&lt;p&gt;Read the pipeline left to right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Postgres (WAL) -&amp;gt; replication slot + publication -&amp;gt; Debezium in Kafka Connect -&amp;gt; Kafka topics -&amp;gt; your sinks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write-ahead log (WAL):&lt;/strong&gt; Postgres writes every change here before it counts as committed. It's the database's own ledger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logical replication slot:&lt;/strong&gt; a named bookmark in the WAL. It's the database's promise to keep the log from that point on until a reader confirms it has processed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publication:&lt;/strong&gt; the list of tables whose changes Postgres decodes for logical replication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kafka Connect:&lt;/strong&gt; a framework for running connectors that move data in and out of Kafka. &lt;strong&gt;Debezium&lt;/strong&gt; is a &lt;em&gt;source connector&lt;/em&gt; inside a Connect worker. It reads the decoded changes, turns each one into an event and writes it to a Kafka topic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kafka Connect also stores the connector's position (the last log sequence number, or &lt;strong&gt;LSN&lt;/strong&gt;, it handled) in an internal &lt;em&gt;offsets&lt;/em&gt; topic. Offset plus slot is how Debezium resumes after a restart. &lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; is about what happens when they disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: prepare Postgres
&lt;/h2&gt;

&lt;p&gt;Postgres needs three things before any connector config.&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="c1"&gt;-- postgresql.conf (restart required)&lt;/span&gt;
&lt;span class="n"&gt;wal_level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logical&lt;/span&gt;
&lt;span class="c1"&gt;-- On Amazon RDS, set rds.logical_replication = 1 in the&lt;/span&gt;
&lt;span class="c1"&gt;-- parameter group instead (static parameter, reboot needed).&lt;/span&gt;

&lt;span class="c1"&gt;-- A dedicated CDC user with only what it needs&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;REPLICATION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Set its password from psql with: \password debezium&lt;/span&gt;
&lt;span class="c1"&gt;-- On RDS, grant the rds_replication role instead of the REPLICATION attribute:&lt;/span&gt;
&lt;span class="c1"&gt;-- GRANT rds_replication TO debezium;&lt;/span&gt;

&lt;span class="c1"&gt;-- Run as the table owner: capture only the tables you need&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;dbz_shop&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wal_level = logical&lt;/code&gt; makes the WAL carry enough row-level detail to decode. The user needs &lt;code&gt;REPLICATION&lt;/code&gt; plus &lt;code&gt;SELECT&lt;/code&gt; (for the initial snapshot). And the publication lists only the tables you want.&lt;/p&gt;

&lt;p&gt;Why create the publication yourself? With &lt;code&gt;pgoutput&lt;/code&gt;, Debezium's default &lt;code&gt;publication.autocreate.mode&lt;/code&gt; is &lt;code&gt;all_tables&lt;/code&gt;. If the publication doesn't exist, it tries to run &lt;code&gt;CREATE PUBLICATION ... FOR ALL TABLES&lt;/code&gt;, which needs superuser rights that managed databases rarely hand out. Set &lt;code&gt;publication.autocreate.mode&lt;/code&gt; to &lt;code&gt;filtered&lt;/code&gt; so Debezium builds it from your table list, or to &lt;code&gt;disabled&lt;/code&gt; and own the publication like any other database object. This post uses &lt;code&gt;disabled&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Replica identity: what goes into &lt;code&gt;before&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Replica identity&lt;/strong&gt; is the table setting that decides how much of the old row Postgres writes to the WAL for updates and deletes. With the default, the &lt;code&gt;before&lt;/code&gt; image in a Debezium update or delete event carries only primary key columns (and for an update, only when the key itself changed). If downstream consumers need the full old row, switch that table to &lt;code&gt;FULL&lt;/code&gt;:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;REPLICA&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;FULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off: every update and delete on that table now writes the whole old row to the WAL, so the log grows faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the connector config, line by line
&lt;/h2&gt;

&lt;p&gt;You register a connector by sending JSON to the Kafka Connect REST API. Here's the whole thing:&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;"name"&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-cdc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"config"&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.class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.debezium.connector.postgresql.PostgresConnector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tasks.max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.hostname"&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-db.internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5432"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"debezium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${file:/secrets/pg.properties:password}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.dbname"&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;"topic.prefix"&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;"plugin.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pgoutput"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"slot.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"debezium_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;"publication.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dbz_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;"publication.autocreate.mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disabled"&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.include.list"&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.orders,public.customers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"snapshot.mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"initial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"heartbeat.interval.ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10000"&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it and check its status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://connect:8083/connectors &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; @shop-cdc.json

curl http://connect:8083/connectors/shop-cdc/status
&lt;span class="c"&gt;# {"name":"shop-cdc","connector":{"state":"RUNNING",...},"tasks":[{"id":0,"state":"RUNNING",...}],...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To change it later, &lt;code&gt;PUT&lt;/code&gt; just the inner &lt;code&gt;config&lt;/code&gt; object to &lt;code&gt;/connectors/shop-cdc/config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What each line does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;connector.class&lt;/code&gt;&lt;/strong&gt; names the Debezium Postgres connector. &lt;strong&gt;&lt;code&gt;tasks.max&lt;/code&gt;&lt;/strong&gt; is &lt;code&gt;1&lt;/code&gt; because this connector always runs a single task (one slot is read by one reader).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;database.*&lt;/code&gt;&lt;/strong&gt; says where to connect. Don't put the password in plain text. That &lt;code&gt;file:&lt;/code&gt; reference in the password line is a Kafka Connect &lt;em&gt;config provider&lt;/em&gt;: the worker reads the value from a file at runtime. Enable it in the worker config:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;  &lt;span class="py"&gt;config.providers&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;
  &lt;span class="py"&gt;config.providers.file.class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.apache.kafka.common.config.provider.FileConfigProvider&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;topic.prefix&lt;/code&gt;&lt;/strong&gt; becomes the first part of every topic name. The Debezium docs say not to change it later, so pick it carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;plugin.name&lt;/code&gt;&lt;/strong&gt; is the first default that bites. The default is &lt;code&gt;decoderbufs&lt;/code&gt;, a logical decoding plugin you'd have to install on the database server yourself. &lt;code&gt;pgoutput&lt;/code&gt; is built into Postgres 10 and later, so set it explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;slot.name&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;publication.name&lt;/code&gt;&lt;/strong&gt; default to &lt;code&gt;debezium&lt;/code&gt; and &lt;code&gt;dbz_publication&lt;/code&gt;. Give every connector its own. Two connectors on one slot means changes go missing without an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;table.include.list&lt;/code&gt;&lt;/strong&gt; is the allow-list of tables to capture. &lt;strong&gt;&lt;code&gt;snapshot.mode&lt;/code&gt;&lt;/strong&gt; is covered below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;heartbeat.interval.ms&lt;/code&gt;&lt;/strong&gt; is the second default that bites. It's &lt;code&gt;0&lt;/code&gt; (off) by default. Turn it on. Without heartbeats, a connector whose tables rarely change can stop confirming its position, and the slot holds WAL until the disk fills. Part 6 covers that trap in depth.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: snapshot, then stream, with no gap
&lt;/h2&gt;

&lt;p&gt;So what happens on the first start?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Debezium creates the replication slot if it doesn't exist.&lt;/li&gt;
&lt;li&gt;It opens a transaction and records the current log position. Call it &lt;strong&gt;P&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It scans every included table, emitting one event per row with &lt;code&gt;op&lt;/code&gt; = &lt;code&gt;r&lt;/code&gt; (read).&lt;/li&gt;
&lt;li&gt;It records in its offsets that the snapshot is complete.&lt;/li&gt;
&lt;li&gt;It streams changes from &lt;strong&gt;P&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step is the whole trick. Changes made &lt;em&gt;during&lt;/em&gt; the scan wait in the slot after P and are streamed once the snapshot is done. Nothing is skipped. If an interviewer asks how Debezium avoids a gap between snapshot and stream, that's the one-sentence answer.&lt;/p&gt;

&lt;p&gt;One caveat: if the connector dies halfway through the initial snapshot, the snapshot starts again from the beginning.&lt;/p&gt;

&lt;p&gt;Other &lt;code&gt;snapshot.mode&lt;/code&gt; values worth knowing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Snapshot on first start (no offsets), then stream. The default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;always&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Snapshot on every start, then stream. Can help after a failover.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip the snapshot; stream only new changes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initial_only&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Snapshot, then stop. No streaming.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;when_needed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Snapshot only if there are no offsets or the stored position is no longer available.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For tables too big to scan in one go, there are &lt;strong&gt;incremental snapshots&lt;/strong&gt;: you send a signal (Part 3 shows one), and Debezium copies the table in chunks while streaming keeps running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Topics, keys and partitions
&lt;/h2&gt;

&lt;p&gt;Each table gets its own topic, named &lt;code&gt;&amp;lt;topic.prefix&amp;gt;.&amp;lt;schema&amp;gt;.&amp;lt;table&amp;gt;&lt;/code&gt;. So &lt;code&gt;public.orders&lt;/code&gt; becomes &lt;code&gt;shop.public.orders&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;message key&lt;/strong&gt; is the row's primary key, for example &lt;code&gt;{"id": 1042}&lt;/code&gt;. That matters because Kafka sends every message with the same key to the same partition, and a partition is read in the order it was written. So all changes to order 1042 stay in order. Across different keys there's no ordering promise at all. Part 4 builds on exactly that.&lt;/p&gt;

&lt;p&gt;You'll also see a heartbeat topic (named &lt;code&gt;__debezium-heartbeat.shop&lt;/code&gt; with this prefix) and Kafka Connect's internal topics for configs, offsets and status. Decide partitions, replication factor and retention for your change topics up front, either by creating the topics yourself or with Connect's topic creation settings in the connector config:&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="nl"&gt;"topic.creation.default.partitions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"topic.creation.default.replication.factor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"topic.creation.default.retention.ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"604800000"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't let change topics inherit whatever the broker defaults happen to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside a change event
&lt;/h2&gt;

&lt;p&gt;Say order 1042 moves from &lt;code&gt;paid&lt;/code&gt; to &lt;code&gt;shipped&lt;/code&gt;. The key is just the primary key. The value is an &lt;em&gt;envelope&lt;/em&gt;:&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;"before"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;"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;1042&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shipped"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12990&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"customer_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;77&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;"name"&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;"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;1792591391000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"snapshot"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"false"&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;"orders"&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;88231&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;3456120880&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;"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;"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;1792591391412&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;before&lt;/code&gt;&lt;/strong&gt; is the old row: &lt;code&gt;null&lt;/code&gt; here because the table uses the default replica identity. With &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt; it would hold &lt;code&gt;"status": "paid"&lt;/code&gt; and the rest of the old row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;after&lt;/code&gt;&lt;/strong&gt; is the new row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;op&lt;/code&gt;&lt;/strong&gt; is the operation: &lt;code&gt;c&lt;/code&gt; create, &lt;code&gt;u&lt;/code&gt; update, &lt;code&gt;d&lt;/code&gt; delete, &lt;code&gt;r&lt;/code&gt; snapshot read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source&lt;/code&gt;&lt;/strong&gt; is metadata: connector, database, schema, table, transaction ID and LSN.&lt;/li&gt;
&lt;li&gt;Two timestamps: &lt;strong&gt;&lt;code&gt;source.ts_ms&lt;/code&gt;&lt;/strong&gt; is when the change was committed in the database; the top-level &lt;strong&gt;&lt;code&gt;ts_ms&lt;/code&gt;&lt;/strong&gt; is when Debezium processed it. Subtract one from the other and you have capture lag for every single event (412 ms in this example).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;strong&gt;delete&lt;/strong&gt; has &lt;code&gt;after&lt;/code&gt; set to &lt;code&gt;null&lt;/code&gt;, and by default it's followed by a &lt;strong&gt;tombstone&lt;/strong&gt;: a message with the same key and a &lt;code&gt;null&lt;/code&gt; value, so Kafka log compaction can eventually drop that key. And with the plain JSON converter and &lt;code&gt;schemas.enable=true&lt;/code&gt;, every message carries its full schema alongside the payload. Avro or Protobuf with a schema registry sends a small schema ID instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five common Debezium mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Leaving &lt;code&gt;plugin.name&lt;/code&gt; at the default.&lt;/strong&gt; &lt;code&gt;decoderbufs&lt;/code&gt; isn't installed on most servers, so the connector won't start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two connectors sharing one slot.&lt;/strong&gt; That's silent data loss. One connector, one slot, one publication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;slot.drop.on.stop=true&lt;/code&gt; outside a dev environment.&lt;/strong&gt; Stop the connector and the slot, with its position, is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting &lt;code&gt;before&lt;/code&gt; to hold the old row&lt;/strong&gt; without &lt;code&gt;REPLICA IDENTITY FULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No heartbeat and no alert on how much WAL the slot is holding.&lt;/strong&gt; That one can take down the database itself.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Postgres needs logical WAL, a replication user and a publication you own.&lt;/li&gt;
&lt;li&gt;The connector needs &lt;code&gt;pgoutput&lt;/code&gt;, its own slot, a fixed &lt;code&gt;topic.prefix&lt;/code&gt; and heartbeats.&lt;/li&gt;
&lt;li&gt;On first start it snapshots as of a recorded log position, then streams from that exact position.&lt;/li&gt;
&lt;li&gt;Each table becomes a topic keyed by primary key, so ordering is per row.&lt;/li&gt;
&lt;li&gt;Each event carries &lt;code&gt;before&lt;/code&gt;, &lt;code&gt;after&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;op&lt;/code&gt; and &lt;code&gt;ts_ms&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The CDC in Production series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: Change Data Capture (CDC) Explained (&lt;a href="https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582"&gt;article&lt;/a&gt; · &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 2: Debezium Postgres to Kafka (this post · &lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Next, Part 3: The Silent CDC Bug: Data Loss With No Errors (&lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Coming up: Part 4 (deletes, tombstones and the idempotent MERGE), Part 5 (schema changes), Part 6 (the WAL slot trap)&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/XAUP1BoYuY8" 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, in Part 3: the bug that loses data without a single error, and how reconciliation catches it.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>postgres</category>
      <category>kafka</category>
      <category>tutorial</category>
    </item>
    <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>
