Blog
7 min read

Change Data Capture in Postgres: Streaming Every Row Change

Change data capture streams every insert, update and delete out of Postgres as it happens. How logical decoding and replication slots work, publications and pgoutput, REPLICA IDENTITY, Debezium and lighter alternatives, the outbox pattern, and the operational traps — retained WAL, schema changes and failover.

Sooner or later something outside your database needs to know when data changes: a search index, a cache, an analytics warehouse, another service. The tempting answer is to write to both from application code — INSERT into Postgres, then push to Elasticsearch. That's a dual write, and it drifts: the second write fails, or happens in a different order, or the transaction rolls back after the event was sent.

Change data capture (CDC) reads changes from the database's own log instead. Every committed change, in commit order, exactly as Postgres saw it. (Transactional outbox pattern)

Three ways to capture changes

Approach How Downsides
Polling SELECT ... WHERE updated_at > :last on a schedule Misses deletes, misses rows with clock skew or long transactions, adds load
Triggers Trigger writes every change into an audit table; consumer reads it Write amplification inside every transaction; you maintain the triggers
Logical decoding Read the write-ahead log and decode it into row changes Needs wal_level = logical and careful slot management

Logical decoding is what "CDC" usually means for Postgres, and what tools like Debezium use.

How logical decoding works

Every change is already written to the WAL for durability. With wal_level = logical, Postgres writes enough extra information to reconstruct row-level changes from it. (Postgres WAL explained)

The pieces:

  • Replication slot — a named, persistent cursor into the WAL. It remembers how far the consumer has confirmed, and Postgres retains all WAL after that point until the consumer catches up.
  • Output plugin — turns decoded changes into a format. pgoutput is built in (used by native logical replication and most CDC tools); wal2json and test_decoding are alternatives.
  • Publication — with pgoutput, defines which tables (and optionally which columns and rows) are included.

Changes are delivered per transaction, in commit order, only after commit. Rolled-back transactions never appear.

Setting it up

-- postgresql.conf (requires restart)
-- wal_level = logical
-- max_replication_slots = 10
-- max_wal_senders = 10

CREATE PUBLICATION app_cdc FOR TABLE orders, customers;

-- A role for the consumer
CREATE ROLE cdc_reader WITH LOGIN REPLICATION PASSWORD '...';
GRANT SELECT ON orders, customers TO cdc_reader;

Try it by hand with the SQL interface and a text plugin:

SELECT pg_create_logical_replication_slot('demo', 'test_decoding');
INSERT INTO orders (id, status) VALUES (42, 'pending');
SELECT * FROM pg_logical_slot_get_changes('demo', NULL, NULL);
-- BEGIN 1234
-- table public.orders: INSERT: id[integer]:42 status[text]:'pending'
-- COMMIT 1234
SELECT pg_drop_replication_slot('demo');   -- don't leave demo slots behind!

Real consumers use the streaming replication protocol instead, receiving changes continuously and sending back the LSN they've safely processed.

REPLICA IDENTITY: what you get for updates and deletes

By default an UPDATE or DELETE event includes only the primary key of the old row. If your consumer needs the previous values (to update a denormalised copy or emit a diff):

ALTER TABLE orders REPLICA IDENTITY FULL;

That logs the entire old row — more WAL volume, so use it only where needed. Tables without a primary key and without REPLICA IDENTITY set can't publish updates and deletes at all; Postgres will reject them on the publisher.

Also note TOAST: large column values that didn't change in an update are not included in the event unless replica identity is FULL. Consumers must handle "unchanged TOASTed value" markers. (Postgres TOAST)

Consumers

  • Debezium — the standard. Runs as a Kafka Connect connector (or Debezium Server, which writes to Redis Streams, Pub/Sub, Kinesis and others without Kafka). Takes an initial snapshot of existing rows, then streams changes, with schemas, ordering per key, and at-least-once delivery.
  • Native logical replication — CREATE SUBSCRIPTION on another Postgres. Great for Postgres-to-Postgres (zero-downtime upgrades, splitting a database), not a general event stream.
  • Managed pipelines and ETL tools (Airbyte, Fivetran, PeerDB, Sequin and similar) — CDC into a warehouse or queue without running Kafka.
  • Your own consumer — libraries exist for Node, Go, Python and Rust. Fine for one simple use case; you'll be reimplementing snapshotting, reconnects and offset storage.

Whatever you use, design for at-least-once delivery: after a crash, the consumer replays from its last confirmed LSN, so the same change can arrive twice. Make consumers idempotent — upsert by key and ignore events older than what you've already applied. (Idempotency keys)

CDC and the outbox pattern

Raw table CDC exposes your internal schema as a public contract: rename a column and every consumer breaks. For events meant for other services, combine CDC with an outbox:

BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 42;
INSERT INTO outbox (aggregate_id, type, payload)
  VALUES (42, 'OrderPaid', '{"orderId":42,"amount":1999}');
COMMIT;

CDC publishes only the outbox table. The event is atomic with the business change, the payload is a deliberate contract, and Debezium has an outbox event router built for exactly this. You can even skip the table entirely with pg_logical_emit_message(), which writes a message straight into the WAL inside the transaction.

The traps

Retained WAL filling the disk

This is the one that takes production down. If the consumer stops — crashed connector, deleted pipeline, a forgotten test slot — the slot keeps holding WAL, and pg_wal grows until the disk is full and Postgres stops accepting writes. (No space left on device)

SELECT slot_name, plugin, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag
FROM pg_replication_slots;

Defences: set max_slot_wal_keep_size so a stuck slot is invalidated instead of filling the disk (you'll need to re-snapshot, which beats an outage); alert on slot lag; drop slots you don't use.

Idle databases still growing

A slot only advances when the consumer confirms an LSN, and a consumer only sees LSNs for tables it subscribes to. If those tables are quiet while others are busy, WAL can pile up. Debezium's heartbeat setting (periodically writing to a heartbeat table in the publication) keeps the slot moving.

Schema changes

DDL is not replicated through logical decoding. Adding a column works naturally (new events include it), but consumers must tolerate it; renames and type changes need coordination. Treat schema changes on CDC tables like API changes. (Postgres migrations on large tables)

Failover

Historically, logical slots lived only on the primary; after failing over to a replica, consumers had to re-snapshot. Postgres 17 added failover slots, which a standby keeps synchronised so CDC can survive a promotion. It takes more than one switch:

  • On the primary: create the slot with the failover option (or failover = true on a subscription), and list the standby's physical slot in synchronized_standby_slots so logical consumers never get ahead of the standby.
  • On the standby: sync_replication_slots = on, a physical slot via primary_slot_name, hot_standby_feedback = on, and a dbname in primary_conninfo.

Check that your CDC tool can create slots with the failover option. (Postgres read replicas)

Long transactions

Changes are emitted at commit, and a huge transaction is decoded in memory up to logical_decoding_work_mem, then spilled to disk (or streamed in progress, if the consumer supports it). A one-hour backfill in a single transaction becomes one enormous burst for every consumer. Batch your backfills.

When you don't need CDC

If you have one app and one database, a background job reading an outbox table with SELECT ... FOR UPDATE SKIP LOCKED gives you reliable events without slots, Kafka or Debezium. Reach for CDC when multiple systems need a faithful, ordered copy of changes and polling has stopped being good enough. (Postgres SKIP LOCKED job queue)


EasySpawn runs Postgres on your own server, so you control wal_level and replication slots — and Claude Code can help you set up a publication and keep an eye on slot lag. See how it works or join the waitlist.

Related: Postgres WAL Explained · Transactional Outbox Pattern · Postgres Read Replicas · Idempotency Keys

Keep reading