{"schemaVersion":1,"id":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium","title":"PostgreSQL CDC with Debezium: Logical Replication, WAL Decoding, and Real Event-Driven Architecture","description":"A deep technical guide to PostgreSQL Change Data Capture with Debezium, logical replication slots, WAL decoding internals, the transactional outbox pattern, and building production event-driven architecture with Kafka Connect. With real configurations and gotchas from production.","language":"en","datePublished":"2026-01-13","dateModified":null,"contentFormat":"text/markdown","content":"Databases\n\n# PostgreSQL CDC with Debezium: Logical Replication, WAL Decoding, and Real Event-Driven Architecture\n\n2026-01-136 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/postgres-logical-replication-cdc-debezium>)\n\n## PostgreSQL WAL Decoding Internals\n\nPostgreSQL's Write-Ahead Log (WAL) records every change to the database at the physical level (page and tuple modifications). Logical replication adds a decoding layer that translates these physical changes into logical operations (INSERT, UPDATE, DELETE on specific tables).\n\n```\nPhysical WAL Record (simplified):\n LSN: 0/16B3740\n Resource: Heap\n Type: INSERT\n Relation: 16384/16399/16402 (dboid/tsoid/reloid)\n Block: 42\n Offset: 3\n Data: \\x0001000200034A6F686E... (raw tuple bytes)\n\nLogical Decoding Output (pgoutput plugin):\n BEGIN txid=12345\n INSERT INTO public.orders (id, customer_id, total, status)\n VALUES (9001, 42, 159.99, 'pending')\n COMMIT txid=12345\n```\n\nThe key components:\n\n```sql\n-- 1. Enable logical replication in postgresql.conf\n-- wal_level = logical\n-- max_replication_slots = 10\n-- max_wal_senders = 10\n\n-- 2. Create a publication (what tables to capture)\nCREATE PUBLICATION my_publication FOR TABLE\n orders, order_items, customers\n WITH (publish = 'insert, update, delete');\n\n-- 3. Create a replication slot (consumer cursor)\nSELECT pg_create_logical_replication_slot(\n 'debezium_slot',\n 'pgoutput' -- The output plugin\n);\n\n-- 4. Peek at changes (without consuming them)\nSELECT * FROM pg_logical_slot_peek_changes(\n 'debezium_slot', NULL, NULL\n);\n\n-- 5. Consume changes (advances the slot position)\nSELECT * FROM pg_logical_slot_get_changes(\n 'debezium_slot', NULL, NULL\n);\n```\n\nThe replication slot is the critical piece. It tells PostgreSQL: \"keep all WAL segments from this LSN position until I confirm I've processed them.\" This guarantees no data loss but introduces the biggest operational risk: if the consumer stops, WAL accumulates.\n\n```sql\n-- Monitor replication slot lag\nSELECT\n slot_name,\n confirmed_flush_lsn,\n pg_current_wal_lsn(),\n pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes,\n pg_size_pretty(pg_wal_lsn_diff(\n pg_current_wal_lsn(), confirmed_flush_lsn\n )) AS lag_pretty\nFROM pg_replication_slots\nWHERE slot_type = 'logical';\n\n-- Output:\n-- slot_name | confirmed_flush_lsn | pg_current_wal_lsn | lag_bytes | lag_pretty\n-- debezium_slot| 0/16B3740 | 0/16B4A20 | 4832 | 4832 bytes\n```\n\n## Debezium Configuration for Production\n\nThe following is a production-grade [Kafka Connect](<https://kafka.apache.org/documentation/#connect>) configuration using [Debezium](<https://debezium.io/>):\n\n```json\n{\n \"name\": \"pg-orders-connector\",\n \"config\": {\n \"connector.class\": \"io.debezium.connector.postgresql.PostgresConnector\",\n\n \"database.hostname\": \"pg-primary.internal\",\n \"database.port\": \"5432\",\n \"database.user\": \"debezium_replication\",\n \"database.password\": \"${env:DEBEZIUM_PG_PASSWORD}\",\n \"database.dbname\": \"orders_db\",\n\n \"topic.prefix\": \"orders\",\n \"schema.include.list\": \"public\",\n \"table.include.list\": \"public.orders,public.order_items,public.customers\",\n\n \"plugin.name\": \"pgoutput\",\n \"slot.name\": \"debezium_orders\",\n \"slot.drop.on.stop\": \"false\",\n \"publication.name\": \"dbz_orders_pub\",\n \"publication.autocreate.mode\": \"filtered\",\n\n \"snapshot.mode\": \"initial\",\n \"snapshot.lock.timeout.ms\": \"10000\",\n\n \"tombstones.on.delete\": \"true\",\n \"column.exclude.list\": \"public.customers.ssn,public.customers.credit_card\",\n\n \"transforms\": \"route,unwrap\",\n \"transforms.route.type\": \"io.debezium.transforms.ByLogicalTableRouter\",\n \"transforms.route.topic.regex\": \"orders\\\\.public\\\\.(.*)\",\n \"transforms.route.topic.replacement\": \"domain.events.$1\",\n \"transforms.unwrap.type\": \"io.debezium.transforms.ExtractNewRecordState\",\n \"transforms.unwrap.drop.tombstones\": \"false\",\n \"transforms.unwrap.delete.handling.mode\": \"rewrite\",\n\n \"heartbeat.interval.ms\": \"30000\",\n \"heartbeat.action.query\": \"INSERT INTO debezium_heartbeat (id, ts) VALUES (1, now()) ON CONFLICT (id) DO UPDATE SET ts = now()\",\n\n \"errors.tolerance\": \"all\",\n \"errors.deadletterqueue.topic.name\": \"dlq.pg-orders\",\n \"errors.deadletterqueue.context.headers.enable\": \"true\"\n }\n}\n```\n\nKey decisions explained:\n\n- ` slot.drop.on.stop: false `, Never drop the slot on connector restart. Losing the slot means full re-snapshot.\n- ` heartbeat.interval.ms: 30000 `, Sends a heartbeat every 30s. Without this, if you're filtering tables and no changes occur on captured tables, the slot position never advances, causing WAL accumulation.\n- ` heartbeat.action.query `, Writes to a heartbeat table to force WAL progress even when filtered tables are quiet.\n- ` errors.tolerance: all `, Dead-letter queue instead of crashing the connector on bad records.\n\n## The Transactional Outbox Pattern\n\nThis pattern addresses the fundamental challenge of event-driven systems with relational databases. Instead of trying to atomically write to both Postgres and Kafka (impossible without distributed transactions), you write the event to an outbox table within the same database transaction:\n\n```sql\n-- Create the outbox table\nCREATE TABLE outbox (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n aggregate_type VARCHAR(255) NOT NULL,\n aggregate_id VARCHAR(255) NOT NULL,\n event_type VARCHAR(255) NOT NULL,\n payload JSONB NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\n-- Application code: atomic business logic + event\nBEGIN;\n -- Business logic\n UPDATE orders SET status = 'shipped', shipped_at = now()\n WHERE id = 9001;\n\n -- Publish event (same transaction!)\n INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)\n VALUES (\n 'Order',\n '9001',\n 'OrderShipped',\n '{\"order_id\": 9001, \"shipped_at\": \"2026-02-28T10:30:00Z\",\n \"tracking_number\": \"1Z999AA10123456784\"}'::jsonb\n );\nCOMMIT;\n-- Both succeed or both fail. No dual-write problem.\n```\n\nDebezium captures the outbox insert and routes it to the appropriate Kafka topic:\n\n```json\n{\n \"transforms\": \"outbox\",\n \"transforms.outbox.type\": \"io.debezium.transforms.outbox.EventRouter\",\n \"transforms.outbox.table.fields.additional.placement\": \"event_type:header:eventType\",\n \"transforms.outbox.table.field.event.id\": \"id\",\n \"transforms.outbox.table.field.event.key\": \"aggregate_id\",\n \"transforms.outbox.table.field.event.payload\": \"payload\",\n \"transforms.outbox.table.field.event.payload.id\": \"aggregate_id\",\n \"transforms.outbox.route.by.field\": \"aggregate_type\",\n \"transforms.outbox.route.topic.replacement\": \"domain.events.${routedByValue}\"\n}\n```\n\nThe event ends up in Kafka topic ` domain.events.Order ` with key ` 9001 ` and the JSON payload. The outbox table can be periodically truncated since Debezium has already captured the events.\n\n## Production Gotchas\n\n### 1. TOAST columns and UPDATE events\n\nPostgreSQL stores large column values in TOAST (The Oversized-Attribute Storage Technique) tables. When you UPDATE a row but don't modify a TOASTed column, the WAL record doesn't include the unchanged TOAST value. Debezium sees ` null ` for that column.\n\n```sql\n-- Set REPLICA IDENTITY FULL to include all columns in WAL\nALTER TABLE orders REPLICA IDENTITY FULL;\n\n-- Or, if that's too expensive (doubles WAL size for updates):\n-- Set a specific index as the identity\nALTER TABLE orders REPLICA IDENTITY USING INDEX orders_pkey;\n```\n\n### 2. Schema changes break everything\n\nAdding a column to a captured table can cause Debezium to throw schema mismatch errors. Always:\n\n```sql\n-- 1. Add the column with a default\nALTER TABLE orders ADD COLUMN priority INTEGER DEFAULT 0;\n\n-- 2. Wait for Debezium to process past the schema change LSN\n\n-- 3. Then start writing non-default values\n```\n\n### 3. Slot bloat during maintenance\n\nDuring pg\\_dump or vacuum full, no LSN advances. If your backup takes 4 hours and you have 10K writes/second, that's ~144M WAL records retained.\n\n```bash\n# Monitor slot lag in bytes, alert at 1GB\npg_replication_slots_lag_bytes{slot_name=\"debezium_orders\"} > 1073741824\n```\n\n## The Final Architecture\n\nAfter six months of iteration in a representative production deployment:\n\n```\n┌─────────────────┐ ┌──────────────┐ ┌───────────────┐\n│ PostgreSQL │ │ Debezium │ │ Kafka │\n│  │ WAL │ (Kafka │ │ │\n│ orders ├────→│ Connect) ├────→│ domain.events │\n│ outbox │ │ │ │ .Order │\n│ order_items │ │ Transforms: │ │ .Customer │\n│ │ │ - Outbox │ │ .OrderItem │\n└─────────────────┘ │ - Route │ └───────┬───────┘\n  └──────────────┘ │\n   ┌──────┴──────┐\n   │ Consumers │\n   │ - Search │\n   │ - Analytics │\n   │ - Notify │\n   └─────────────┘\n```\n\nThe outbox pattern handles domain events. Direct CDC handles materialized view updates for search indexes and analytics. The two patterns complement each other, outbox for intentional events, CDC for data synchronization.\n\nTotal end-to-end latency from Postgres commit to Kafka consumer: p50 = 45ms, p99 = 180ms. For an event-driven system built on top of a monolithic database, those numbers are more than acceptable.\n\n## Frequently asked questions\n\n### What is Change Data Capture (CDC) and why use it with PostgreSQL?\n\nCDC captures row-level changes (inserts, updates, deletes) from a database and streams them as events. With PostgreSQL, Debezium uses logical replication to read the Write-Ahead Log (WAL), which means zero impact on application queries and no polling overhead. It enables real-time event-driven architectures without modifying application code.\n\n### What are PostgreSQL logical replication slots?\n\nReplication slots are server-side cursors that track a consumer's position in the WAL. They guarantee that PostgreSQL retains WAL segments until the consumer confirms processing, preventing data loss. The downside is that a stalled consumer can cause WAL accumulation and disk exhaustion.\n\n### When should I use the transactional outbox pattern?\n\nUse the outbox when you need to atomically update your database and publish an event. Instead of dual-writing to both Postgres and Kafka, you write the event to an outbox table within the same transaction, and Debezium captures it via CDC. This guarantees exactly-once semantics between your database and message broker.\n\n## Tips\n\n1. **Tip:** Always set ` slot.drop.on.stop=false ` in production Debezium connectors. If you accidentally drop the replication slot, Postgres will recycle the WAL segments, and you'll need a full re-snapshot of every table. This is a lesson commonly learned the hard way during late-night incidents.\n2. **Tip:** Monitor ` pg_replication_slots.confirmed_flush_lsn ` vs ` pg_current_wal_lsn() `. If the gap keeps growing, your consumer is falling behind and Postgres is retaining WAL segments, eventually you'll run out of disk. Set up an alert when the lag exceeds 1 GB.\n3. **Tip:** The transactional outbox pattern with Debezium is the only reliable way to publish domain events from a PostgreSQL transaction. Dual-writes to Postgres and Kafka will *always* have a failure window where one succeeds and the other doesn't. The outbox eliminates this completely.\n4. **Tip:** Use ` publication.autocreate.mode=filtered ` and explicitly list your tables. Default ` all_tables ` will capture every table including temp tables, audit logs, and that forgotten migration table, flooding your Kafka topics with noise.\n\n- postgresql\n- cdc\n- debezium\n- kafka\n- logical-replication\n- wal\n- event-driven\n- outbox-pattern\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"PostgreSQL CDC with Debezium: Logical Replication, WAL Decoding, and Real Event-Driven Architecture","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#title"},{"level":2,"text":"PostgreSQL WAL Decoding Internals","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#postgresql-wal-decoding-internals"},{"level":2,"text":"Debezium Configuration for Production","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#debezium-configuration-for-production"},{"level":2,"text":"The Transactional Outbox Pattern","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#the-transactional-outbox-pattern"},{"level":2,"text":"Production Gotchas","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#production-gotchas"},{"level":3,"text":"1. TOAST columns and UPDATE events","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#1-toast-columns-and-update-events"},{"level":3,"text":"2. Schema changes break everything","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#2-schema-changes-break-everything"},{"level":3,"text":"3. Slot bloat during maintenance","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#3-slot-bloat-during-maintenance"},{"level":2,"text":"The Final Architecture","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#the-final-architecture"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#questions"},{"level":3,"text":"What is Change Data Capture (CDC) and why use it with PostgreSQL?","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#question-1"},{"level":3,"text":"What are PostgreSQL logical replication slots?","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#question-2"},{"level":3,"text":"When should I use the transactional outbox pattern?","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#article-tips"}],"sections":[{"heading":"PostgreSQL CDC with Debezium: Logical Replication, WAL Decoding, and Real Event-Driven Architecture","level":1,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium","content":"Databases"},{"heading":"PostgreSQL CDC with Debezium: Logical Replication, WAL Decoding, and Real Event-Driven Architecture","level":1,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#title","content":"2026-01-136 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/postgres-logical-replication-cdc-debezium>)"},{"heading":"PostgreSQL WAL Decoding Internals","level":2,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#postgresql-wal-decoding-internals","content":"PostgreSQL's Write-Ahead Log (WAL) records every change to the database at the physical level (page and tuple modifications). Logical replication adds a decoding layer that translates these physical changes into logical operations (INSERT, UPDATE, DELETE on specific tables).\n\n```\nPhysical WAL Record (simplified):\n LSN: 0/16B3740\n Resource: Heap\n Type: INSERT\n Relation: 16384/16399/16402 (dboid/tsoid/reloid)\n Block: 42\n Offset: 3\n Data: \\x0001000200034A6F686E... (raw tuple bytes)\n\nLogical Decoding Output (pgoutput plugin):\n BEGIN txid=12345\n INSERT INTO public.orders (id, customer_id, total, status)\n VALUES (9001, 42, 159.99, 'pending')\n COMMIT txid=12345\n```\n\nThe key components:\n\n```sql\n-- 1. Enable logical replication in postgresql.conf\n-- wal_level = logical\n-- max_replication_slots = 10\n-- max_wal_senders = 10\n\n-- 2. Create a publication (what tables to capture)\nCREATE PUBLICATION my_publication FOR TABLE\n orders, order_items, customers\n WITH (publish = 'insert, update, delete');\n\n-- 3. Create a replication slot (consumer cursor)\nSELECT pg_create_logical_replication_slot(\n 'debezium_slot',\n 'pgoutput' -- The output plugin\n);\n\n-- 4. Peek at changes (without consuming them)\nSELECT * FROM pg_logical_slot_peek_changes(\n 'debezium_slot', NULL, NULL\n);\n\n-- 5. Consume changes (advances the slot position)\nSELECT * FROM pg_logical_slot_get_changes(\n 'debezium_slot', NULL, NULL\n);\n```\n\nThe replication slot is the critical piece. It tells PostgreSQL: \"keep all WAL segments from this LSN position until I confirm I've processed them.\" This guarantees no data loss but introduces the biggest operational risk: if the consumer stops, WAL accumulates.\n\n```sql\n-- Monitor replication slot lag\nSELECT\n slot_name,\n confirmed_flush_lsn,\n pg_current_wal_lsn(),\n pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes,\n pg_size_pretty(pg_wal_lsn_diff(\n pg_current_wal_lsn(), confirmed_flush_lsn\n )) AS lag_pretty\nFROM pg_replication_slots\nWHERE slot_type = 'logical';\n\n-- Output:\n-- slot_name | confirmed_flush_lsn | pg_current_wal_lsn | lag_bytes | lag_pretty\n-- debezium_slot| 0/16B3740 | 0/16B4A20 | 4832 | 4832 bytes\n```"},{"heading":"Debezium Configuration for Production","level":2,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#debezium-configuration-for-production","content":"The following is a production-grade [Kafka Connect](<https://kafka.apache.org/documentation/#connect>) configuration using [Debezium](<https://debezium.io/>):\n\n```json\n{\n \"name\": \"pg-orders-connector\",\n \"config\": {\n \"connector.class\": \"io.debezium.connector.postgresql.PostgresConnector\",\n\n \"database.hostname\": \"pg-primary.internal\",\n \"database.port\": \"5432\",\n \"database.user\": \"debezium_replication\",\n \"database.password\": \"${env:DEBEZIUM_PG_PASSWORD}\",\n \"database.dbname\": \"orders_db\",\n\n \"topic.prefix\": \"orders\",\n \"schema.include.list\": \"public\",\n \"table.include.list\": \"public.orders,public.order_items,public.customers\",\n\n \"plugin.name\": \"pgoutput\",\n \"slot.name\": \"debezium_orders\",\n \"slot.drop.on.stop\": \"false\",\n \"publication.name\": \"dbz_orders_pub\",\n \"publication.autocreate.mode\": \"filtered\",\n\n \"snapshot.mode\": \"initial\",\n \"snapshot.lock.timeout.ms\": \"10000\",\n\n \"tombstones.on.delete\": \"true\",\n \"column.exclude.list\": \"public.customers.ssn,public.customers.credit_card\",\n\n \"transforms\": \"route,unwrap\",\n \"transforms.route.type\": \"io.debezium.transforms.ByLogicalTableRouter\",\n \"transforms.route.topic.regex\": \"orders\\\\.public\\\\.(.*)\",\n \"transforms.route.topic.replacement\": \"domain.events.$1\",\n \"transforms.unwrap.type\": \"io.debezium.transforms.ExtractNewRecordState\",\n \"transforms.unwrap.drop.tombstones\": \"false\",\n \"transforms.unwrap.delete.handling.mode\": \"rewrite\",\n\n \"heartbeat.interval.ms\": \"30000\",\n \"heartbeat.action.query\": \"INSERT INTO debezium_heartbeat (id, ts) VALUES (1, now()) ON CONFLICT (id) DO UPDATE SET ts = now()\",\n\n \"errors.tolerance\": \"all\",\n \"errors.deadletterqueue.topic.name\": \"dlq.pg-orders\",\n \"errors.deadletterqueue.context.headers.enable\": \"true\"\n }\n}\n```\n\nKey decisions explained:\n\n- ` slot.drop.on.stop: false `, Never drop the slot on connector restart. Losing the slot means full re-snapshot.\n- ` heartbeat.interval.ms: 30000 `, Sends a heartbeat every 30s. Without this, if you're filtering tables and no changes occur on captured tables, the slot position never advances, causing WAL accumulation.\n- ` heartbeat.action.query `, Writes to a heartbeat table to force WAL progress even when filtered tables are quiet.\n- ` errors.tolerance: all `, Dead-letter queue instead of crashing the connector on bad records."},{"heading":"The Transactional Outbox Pattern","level":2,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#the-transactional-outbox-pattern","content":"This pattern addresses the fundamental challenge of event-driven systems with relational databases. Instead of trying to atomically write to both Postgres and Kafka (impossible without distributed transactions), you write the event to an outbox table within the same database transaction:\n\n```sql\n-- Create the outbox table\nCREATE TABLE outbox (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n aggregate_type VARCHAR(255) NOT NULL,\n aggregate_id VARCHAR(255) NOT NULL,\n event_type VARCHAR(255) NOT NULL,\n payload JSONB NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\n-- Application code: atomic business logic + event\nBEGIN;\n -- Business logic\n UPDATE orders SET status = 'shipped', shipped_at = now()\n WHERE id = 9001;\n\n -- Publish event (same transaction!)\n INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)\n VALUES (\n 'Order',\n '9001',\n 'OrderShipped',\n '{\"order_id\": 9001, \"shipped_at\": \"2026-02-28T10:30:00Z\",\n \"tracking_number\": \"1Z999AA10123456784\"}'::jsonb\n );\nCOMMIT;\n-- Both succeed or both fail. No dual-write problem.\n```\n\nDebezium captures the outbox insert and routes it to the appropriate Kafka topic:\n\n```json\n{\n \"transforms\": \"outbox\",\n \"transforms.outbox.type\": \"io.debezium.transforms.outbox.EventRouter\",\n \"transforms.outbox.table.fields.additional.placement\": \"event_type:header:eventType\",\n \"transforms.outbox.table.field.event.id\": \"id\",\n \"transforms.outbox.table.field.event.key\": \"aggregate_id\",\n \"transforms.outbox.table.field.event.payload\": \"payload\",\n \"transforms.outbox.table.field.event.payload.id\": \"aggregate_id\",\n \"transforms.outbox.route.by.field\": \"aggregate_type\",\n \"transforms.outbox.route.topic.replacement\": \"domain.events.${routedByValue}\"\n}\n```\n\nThe event ends up in Kafka topic ` domain.events.Order ` with key ` 9001 ` and the JSON payload. The outbox table can be periodically truncated since Debezium has already captured the events."},{"heading":"Production Gotchas","level":2,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#production-gotchas","content":""},{"heading":"1. TOAST columns and UPDATE events","level":3,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#1-toast-columns-and-update-events","content":"PostgreSQL stores large column values in TOAST (The Oversized-Attribute Storage Technique) tables. When you UPDATE a row but don't modify a TOASTed column, the WAL record doesn't include the unchanged TOAST value. Debezium sees ` null ` for that column.\n\n```sql\n-- Set REPLICA IDENTITY FULL to include all columns in WAL\nALTER TABLE orders REPLICA IDENTITY FULL;\n\n-- Or, if that's too expensive (doubles WAL size for updates):\n-- Set a specific index as the identity\nALTER TABLE orders REPLICA IDENTITY USING INDEX orders_pkey;\n```"},{"heading":"2. Schema changes break everything","level":3,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#2-schema-changes-break-everything","content":"Adding a column to a captured table can cause Debezium to throw schema mismatch errors. Always:\n\n```sql\n-- 1. Add the column with a default\nALTER TABLE orders ADD COLUMN priority INTEGER DEFAULT 0;\n\n-- 2. Wait for Debezium to process past the schema change LSN\n\n-- 3. Then start writing non-default values\n```"},{"heading":"3. Slot bloat during maintenance","level":3,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#3-slot-bloat-during-maintenance","content":"During pg\\_dump or vacuum full, no LSN advances. If your backup takes 4 hours and you have 10K writes/second, that's ~144M WAL records retained.\n\n```bash\n# Monitor slot lag in bytes, alert at 1GB\npg_replication_slots_lag_bytes{slot_name=\"debezium_orders\"} > 1073741824\n```"},{"heading":"The Final Architecture","level":2,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#the-final-architecture","content":"After six months of iteration in a representative production deployment:\n\n```\n┌─────────────────┐ ┌──────────────┐ ┌───────────────┐\n│ PostgreSQL │ │ Debezium │ │ Kafka │\n│  │ WAL │ (Kafka │ │ │\n│ orders ├────→│ Connect) ├────→│ domain.events │\n│ outbox │ │ │ │ .Order │\n│ order_items │ │ Transforms: │ │ .Customer │\n│ │ │ - Outbox │ │ .OrderItem │\n└─────────────────┘ │ - Route │ └───────┬───────┘\n  └──────────────┘ │\n   ┌──────┴──────┐\n   │ Consumers │\n   │ - Search │\n   │ - Analytics │\n   │ - Notify │\n   └─────────────┘\n```\n\nThe outbox pattern handles domain events. Direct CDC handles materialized view updates for search indexes and analytics. The two patterns complement each other, outbox for intentional events, CDC for data synchronization.\n\nTotal end-to-end latency from Postgres commit to Kafka consumer: p50 = 45ms, p99 = 180ms. For an event-driven system built on top of a monolithic database, those numbers are more than acceptable."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#questions","content":""},{"heading":"What is Change Data Capture (CDC) and why use it with PostgreSQL?","level":3,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#question-1","content":"CDC captures row-level changes (inserts, updates, deletes) from a database and streams them as events. With PostgreSQL, Debezium uses logical replication to read the Write-Ahead Log (WAL), which means zero impact on application queries and no polling overhead. It enables real-time event-driven architectures without modifying application code."},{"heading":"What are PostgreSQL logical replication slots?","level":3,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#question-2","content":"Replication slots are server-side cursors that track a consumer's position in the WAL. They guarantee that PostgreSQL retains WAL segments until the consumer confirms processing, preventing data loss. The downside is that a stalled consumer can cause WAL accumulation and disk exhaustion."},{"heading":"When should I use the transactional outbox pattern?","level":3,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#question-3","content":"Use the outbox when you need to atomically update your database and publish an event. Instead of dual-writing to both Postgres and Kafka, you write the event to an outbox table within the same transaction, and Debezium captures it via CDC. This guarantees exactly-once semantics between your database and message broker."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#article-tips","content":"1. **Tip:** Always set ` slot.drop.on.stop=false ` in production Debezium connectors. If you accidentally drop the replication slot, Postgres will recycle the WAL segments, and you'll need a full re-snapshot of every table. This is a lesson commonly learned the hard way during late-night incidents.\n2. **Tip:** Monitor ` pg_replication_slots.confirmed_flush_lsn ` vs ` pg_current_wal_lsn() `. If the gap keeps growing, your consumer is falling behind and Postgres is retaining WAL segments, eventually you'll run out of disk. Set up an alert when the lag exceeds 1 GB.\n3. **Tip:** The transactional outbox pattern with Debezium is the only reliable way to publish domain events from a PostgreSQL transaction. Dual-writes to Postgres and Kafka will *always* have a failure window where one succeeds and the other doesn't. The outbox eliminates this completely.\n4. **Tip:** Use ` publication.autocreate.mode=filtered ` and explicitly list your tables. Default ` all_tables ` will capture every table including temp tables, audit logs, and that forgotten migration table, flooding your Kafka topics with noise.\n\n- postgresql\n- cdc\n- debezium\n- kafka\n- logical-replication\n- wal\n- event-driven\n- outbox-pattern\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/postgres-logical-replication-cdc-debezium"},{"text":"Kafka Connect","url":"https://kafka.apache.org/documentation/#connect"},{"text":"Debezium","url":"https://debezium.io/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium"},"headline":"PostgreSQL CDC with Debezium: Logical Replication, WAL Decoding, and Real Event-Driven Architecture","description":"A deep technical guide to PostgreSQL Change Data Capture with Debezium, logical replication slots, WAL decoding internals, the transactional outbox pattern, and building production event-driven architecture with Kafka Connect. With real configurations and gotchas from production.","datePublished":"2026-01-13","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Databases","keywords":"postgresql, cdc, debezium, kafka, logical-replication, wal, event-driven, outbox-pattern","wordCount":984,"timeRequired":"PT6M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/postgres-logical-replication-cdc-debezium"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"PostgreSQL CDC with Debezium: Logical Replication, WAL Decoding, and Real Event-Driven Architecture","item":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is Change Data Capture (CDC) and why use it with PostgreSQL?","acceptedAnswer":{"@type":"Answer","text":"CDC captures row-level changes (inserts, updates, deletes) from a database and streams them as events. With PostgreSQL, Debezium uses logical replication to read the Write-Ahead Log (WAL), which means zero impact on application queries and no polling overhead. It enables real-time event-driven architectures without modifying application code."}},{"@type":"Question","name":"What are PostgreSQL logical replication slots?","acceptedAnswer":{"@type":"Answer","text":"Replication slots are server-side cursors that track a consumer's position in the WAL. They guarantee that PostgreSQL retains WAL segments until the consumer confirms processing, preventing data loss. The downside is that a stalled consumer can cause WAL accumulation and disk exhaustion."}},{"@type":"Question","name":"When should I use the transactional outbox pattern?","acceptedAnswer":{"@type":"Answer","text":"Use the outbox when you need to atomically update your database and publish an event. Instead of dual-writing to both Postgres and Kafka, you write the event to an outbox table within the same transaction, and Debezium captures it via CDC. This guarantees exactly-once semantics between your database and message broker."}}]}]}],"contentHash":"2402fcb7569c878dc53bd0d0ad0847fe23700b344b0f5d040773a657a213c41b","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/postgres-logical-replication-cdc-debezium","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":60539,"contentBytes":11796}}
