Postgres change data capture: how CDC and logical decoding actually work in PostgreSQL
9 min read Databases The Adapters team
Last updated August 2026
Field mapping auto-plugged · tap a port to rewire
Plug a source port into
Transform on this cable
JSON in
JSON out
5 sample records ready
PostgreSQL has native change data capture through logical decoding. You set wal_level to logical, create a publication over the tables you care about, and a replication slot streams every committed insert, update and delete through an output plugin such as pgoutput or wal2json. No triggers, no polling, and almost no load on your tables. The cost is that a slot is a persistent object on production that retains write-ahead log files until somebody reads them, and by default there is no limit on how much it will retain.
Key takeaways
- Postgres CDC is a feature you enable, not a product you buy. The tools in this space are consumers of a stream PostgreSQL already knows how to produce.
- max_slot_wal_keep_size defaults to -1. That means unlimited WAL retention behind a stalled slot. Set a real ceiling before you go live.
- On RDS and Aurora it is a reboot, not a toggle. rds.logical_replication is a static parameter, and Amazon ships only the test_decoding and wal2json plugins.
- A watermark query still wins a surprising share of projects. If hourly freshness is enough and rows are never hard deleted, logical decoding is operational cost you do not need.
Does Postgres support change data capture?
Yes. PostgreSQL has supported logical decoding since version 9.4 and the publication and subscription syntax since version 10, and that combination is genuine log-based change data capture. The database already writes every change to the write-ahead log for durability. Logical decoding reads that same log, turns each record back into a row-level change with a table name and column values, and streams it to a client over a replication connection. Nothing queries your tables and nothing fires on write, so the overhead on normal traffic is close to nothing.
What Postgres does not give you is a pipeline. It hands you an ordered stream of changes and expects a consumer to be there. Everything sold as a Postgres CDC product, from Debezium to the managed services in our change data capture tools comparison, is that consumer plus checkpointing, schema handling, backfill and delivery.
How does Postgres change data capture work?
Four objects do the work. The write-ahead log is the durable record of every change. A publication declares which tables are in scope, created with CREATE PUBLICATION over specific tables or FOR ALL TABLES. A replication slot is a named bookmark that remembers how far a particular consumer has read and guarantees the server keeps the WAL that consumer still needs. An output plugin decides the wire format: pgoutput is built in, wal2json emits JSON, and test_decoding emits a human readable form useful mainly for checking that decoding works at all.
A consumer connects in replication mode, names the slot, and receives changes in commit order. It periodically confirms a log position, which is what allows the server to release older WAL. Creating a subscription on another Postgres instance does this for you and creates the slot on the publisher automatically. You can watch the state of every slot at any time with a single query against pg_replication_slots, where the active column and the restart_lsn tell you whether anybody is actually reading.
How do you set up change data capture in Postgres?
The configuration is short, and every default in the table below comes from the PostgreSQL documentation as it stood in August 2026. Two of the five settings are the ones teams get wrong.
| Parameter | Default | What to set | Why it matters |
|---|---|---|---|
| wal_level | replica | logical | Without it the WAL does not carry enough information to decode row changes |
| max_replication_slots | 10 | One per subscription, plus headroom for table sync | Every CDC consumer holds a slot; running out blocks new connectors |
| max_wal_senders | 10 | At least max_replication_slots, plus physical replicas | Each streaming connection needs a sender process |
| max_slot_wal_keep_size | -1 (unlimited) | A real ceiling in MB or GB | This is the setting that stops an abandoned slot filling the disk |
| rds.logical_replication | 0 (RDS and Aurora) | 1, then reboot the instance | Static parameter; RDS will not enable logical decoding without it |
The order is: set wal_level to logical and restart, size max_replication_slots and max_wal_senders for the number of consumers you expect plus a reserve for table synchronization, create a dedicated role with the REPLICATION attribute, create the publication, then let the consumer create its slot. Give every consumer its own slot with a name you will recognize in six months, because a slot called test_slot that nobody claims is exactly the one that survives a team change and quietly retains WAL.
The replication slot trap that fills your disk
This is the failure worth internalizing before anything else. A replication slot holds write-ahead log files until the consumer confirms it has read past them. PostgreSQL's max_slot_wal_keep_size parameter caps that retention, and its default value is -1, which the documentation states plainly: replication slots may retain an unlimited amount of WAL files. Amazon says the same thing about RDS in blunter terms, warning that if you set up a logical replication slot and do not read from the slot, data can be written and quickly fill up your instance storage.
So the sequence is ordinary and the outcome is not. A connector is paused for maintenance on Friday afternoon. Nobody drops its slot. Write traffic continues all weekend and pg_wal grows without a ceiling, because there is no ceiling. On Monday the volume is full and the database stops accepting writes. It presents as a storage incident rather than a pipeline incident, which is why it usually gets diagnosed late.
Three defenses, in order of value. Set max_slot_wal_keep_size to a real number so Postgres invalidates a hopeless slot instead of protecting it forever. Alert on pg_replication_slots where active is false, and on the size of pg_wal, before the volume is anywhere near full. And keep an independent check outside the database itself, since the moment the disk fills, the monitoring that lives on the same instance stops reporting too: a simple external probe watching the database port every thirty seconds tells you the instance is gone even when nothing inside it can. Then make dropping the slot part of your decommissioning checklist, not a thing someone remembers.
Postgres change data capture on AWS RDS and Aurora
Managed Postgres does not expose postgresql.conf, so you work through a parameter group. Set the static parameter rds.logical_replication to 1, which also adjusts wal_level, max_wal_senders, max_replication_slots and max_connections, then reboot the instance for it to take effect. Amazon is explicit that this increases WAL generation, so it should be turned on only when you are genuinely using logical slots.
Two practical constraints follow. Your user needs both rds_superuser to enable replication and rds_replication to manage slots and stream from them. And RDS ships only the test_decoding and wal2json output plugins from the PostgreSQL distribution, so a tool that insists on some other decoder is not an option on RDS. pgoutput remains available through the native publication and subscription path, which is what most connectors use. Aurora PostgreSQL follows the same pattern with its own cluster parameter, and both flavors reward setting a WAL retention ceiling, because storage that autoscales just means the bill grows instead of the outage arriving.
Postgres change data capture with Kafka
Kafka is the most common destination for raw Postgres CDC, and Debezium is how most teams get there. The Debezium PostgreSQL connector runs on Kafka Connect, holds a logical replication slot, and publishes one topic per table with an envelope containing the before image, the after image, the operation type and source metadata. It is licensed Apache 2.0, and the same project ships connectors for MySQL, MongoDB, SQL Server, Oracle, Db2, Cassandra and Informix, with Vitess and Spanner still incubating.
Be honest about the operating cost. Choosing Debezium means running Kafka, Kafka Connect, a schema registry in most cases, and the dead letter handling around them. That is a platform, and it needs an owner. Confluent Cloud and similar services sell the same connector semantics with the brokers managed, which converts an engineering commitment into a usage bill. The build versus buy math applies here more sharply than almost anywhere else in data engineering, because the software is free and the operation is not.
Change data capture from Postgres to Snowflake
Loading a warehouse is the most common reason anyone reaches for Postgres CDC, and it is also the case where CDC is least often required. Ask what freshness the destination actually needs. If analysts query yesterday's data and a dashboard has to be right at 9am, a scheduled incremental read on an indexed updated_at column meets the requirement with no slots, no plugins and no pager. If the destination drives something operational, or the source hard deletes rows that must disappear downstream, then logical decoding earns its complexity.
Either way the warehouse side has its own rules. Snowflake's NUMBER type defaults to a precision and scale of (38,0), so a Postgres numeric with decimals needs explicit precision or the cents disappear. Unquoted identifiers fold to uppercase in Snowflake and to lowercase in Postgres, which is the mismatch that breaks the first set of queries after go live. Timestamps need a deliberate choice between TIMESTAMP_NTZ, TIMESTAMP_LTZ and TIMESTAMP_TZ rather than whatever the loader picks. We work through the whole mapping on the Postgres to Snowflake connector page, and the same considerations apply to Postgres to BigQuery.
What are the alternatives to logical decoding?
Logical decoding is one of several ways to notice a change, and it is the most expensive to operate. The comparison below is the honest version.
One option that does not appear in the table, because it is not really CDC, is worth naming first. If the destination is another PostgreSQL database rather than a warehouse or a queue, you do not need a consumer at all: Postgres logical replication subscribes directly to the same stream and applies it for you, though it will not carry your schema, your sequences or your large objects. For everything else, the vendors and the native paths are lined up on Postgres ETL tools.
| Approach | What it is | Latency | Catches deletes | Notes |
|---|---|---|---|---|
| pgoutput | Native logical decoding plugin | Seconds | Yes | Built into PostgreSQL since 10, no extension to install, the default for most connectors |
| wal2json | Logical decoding plugin, JSON output | Seconds | Yes | Easier to consume from custom code; shipped by Amazon RDS alongside test_decoding |
| Debezium connector | Logical decoding into Kafka | Seconds | Yes | Apache 2.0; adds Kafka Connect and a broker to your operational surface |
| Managed CDC service | Hosted logical decoding | Seconds to minutes | Yes | No infrastructure, but a usage meter and a vendor holding a slot on production |
| Trigger and audit table | In-database change log | Seconds | Yes | Works on any Postgres including locked-down managed tiers; taxes every write |
| Watermark query | Polling on updated_at or an id | Minutes to hours | No | No privileges, no slots, nothing to page you; the pragmatic default for analytics |
Trigger-based capture still has a place. On a managed tier where you cannot get replication privileges, an AFTER INSERT OR UPDATE OR DELETE trigger writing into an audit table is the only way to see deletes at all. It costs you extra work inside every transaction and a schema object that has to be maintained alongside the table forever, so treat it as a fallback rather than a design.
Watermark polling remains the pragmatic default for analytics. It needs an index on the watermark column, a small overlap window so a long transaction committing after the cursor moved is not missed, and a plan for deletes, usually a soft-delete flag or a periodic key reconciliation. In exchange it works on every Postgres ever deployed and it cannot fill your disk.
Is change data capture worth it for Postgres?
It is worth it when the table is large enough that re-reading it hurts, when the source is production and you cannot add query load, when hard deletes have to propagate, or when a downstream system needs to react within seconds. It is not worth it when a scheduled incremental load already meets the freshness requirement, and choosing it anyway buys you replication slots, WAL retention rules, plugin constraints and a new class of production incident in exchange for latency nobody asked for.
The useful question in a planning meeting is not whether CDC is better. It is who is waiting for this data, and how long they can wait. Write that number down first, because it eliminates most of the shortlist on its own. Then pick the cheapest mechanism that clears it. If you land on scheduled incremental sync, that is what Adapters does: pick the tables, map the fields, choose an interval, and get retries, alerting and per-record logs on a flat monthly price rather than a row meter. The CDC tools comparison lines up the log-based options if you land the other way, and ETL versus ELT covers what happens to the data once it arrives.
Sync Postgres without holding a replication slot
Scheduled incremental extraction with field mapping, retries and per-record logs. Flat price from $49 a month, no row meter.
No credit card required.