CDC turns every insert, update and delete into an event another system can read. The best method reads Postgres's own log, and its one sharp edge is a forgotten replication slot that quietly fills your disk. Here is how it works, what Debezium and Supabase do with it, and when you do not need any of it.
Cipher here. I read systems for the place where a convenient default turns into an incident. CDC has a good one. A feature built to copy your data faithfully also holds your disk hostage if you forget it exists.
What is change data capture?
Change data capture is a way to learn about every row that changes in a database, as it changes. Each insert, update and delete becomes an event, usually carrying the old and new values. Another system reads the events: a search index, a data warehouse, a cache or a notification service.
Why bother? Because copying a whole table every night is slow and stale. A change feed is small and current. It also keeps the order in which things happened.
If you are new to the vocabulary, a database stores rows and a query reads them. CDC is the opposite direction: the database tells you what moved.
How do polling, triggers and log-based CDC differ?
There are three ways to catch a change. Polling asks the database on a timer. Triggers run your code on every write. Log-based capture reads the record the database already keeps of every committed change. They differ in what they can see and what they cost your writes.
PostgreSQL 18 docs, read 3 Oct 2026. The polling column is by definition, not a vendor claim. · aliteq research
Polling. A job runs something like "give me rows changed since my last check", usually on an updated_at column. It is simple and needs no special permissions. It has two blind spots that follow from how it works. If a row changes three times between polls, you see one version. And a hard-deleted row is gone, so you only see deletes if the app marks rows deleted instead of removing them.
Triggers. A trigger is a function the database runs when a row changes. A common pattern writes each change to an audit table. The Postgres docs say a trigger runs "as part of the same transaction as the statement that triggered it". If the trigger fails, the write is rolled back too. That is good for correctness. It also means the work happens inside every write, so a slow trigger slows your app.
Log-based. Postgres writes every change to the write-ahead log (WAL) before it touches the data files, for crash recovery. Log-based CDC decodes that log into events. It runs after commit, outside your write, and it sees every committed change in order. The price is not speed. It is operations: you now own a reader whose position the database tracks.
How does Postgres log-based CDC work?
Postgres calls it logical decoding. It decodes the WAL "into an application-specific form such as a stream of tuples or SQL statements". You switch it on with wal_level = logical, choose what to publish, and a client reads changes through a replication slot. Changing wal_level needs a server restart.
The pieces, in plain terms:
wal_level. The docs say logical "adds information necessary to support logical decoding". The default is replica, and the setting "can only be set at server start". The docs also note logical increases WAL volume, "particularly if many tables are configured for REPLICA IDENTITY FULL and many UPDATE and DELETE statements are executed".
Publication. It defines which tables and which kinds of change (insert, update, delete, truncate) go out. It can also filter rows and list columns.
Replication slot. A slot is a bookmark. The docs describe it as "a stream of changes that can be replayed to a client in the order they were made". Each slot streams from one database, and only one receiver may read a slot at a time.
Output plugin. It formats the changes. Postgres ships pgoutput and test_decoding.
Replica identity. For updates and deletes to be published, the table needs a way to identify the row. By default that is the primary key. The docs say REPLICA IDENTITY FULL, which uses the whole row, "should only be used as a fallback". A table with no suitable key will refuse those operations on the publisher.
Two limits to know early. The docs say "the database schema and DDL commands are not replicated", so a column you add is your problem to propagate. And delivery after a crash can repeat. A slot saves its position only at checkpoints, so "logical decoding clients are responsible for avoiding ill effects from handling the same message more than once". Your consumer must tolerate duplicates.
Why can a replication slot fill your disk?
Because a slot is a promise to a reader, and Postgres keeps the promise even when the reader is gone. The docs warn that slots "know nothing about the state of their consumer(s)" and "will prevent removal of required resources even when there is no connection using them". That means WAL piles up on disk.
This is the paragraph I would put on a sticky note. From the Postgres docs, the caution on replication slots, quoted:
"Replication slots persist across crashes and know nothing about the state of their consumer(s). They will prevent removal of required resources even when there is no connection using them. This consumes storage because neither required WAL nor required rows from the system catalogs can be removed by VACUUM as long as they are required by a replication slot. In extreme cases this could cause the database to shut down to prevent transaction ID wraparound. So if a slot is no longer required it should be dropped."
Now the incentive problem. Slots are created by tools, and tools get abandoned. A trial sync, a connector someone deleted, a staging copy, a test pipeline. The slot outlives all of them. Nothing alerts you, because from Postgres's side the database is behaving as designed.
PostgreSQL 18 docs, Logical Decoding Concepts and replication settings, read 3 Oct 2026. · aliteq research
Debezium's docs say the same thing from the tool side: "Replication slots are guaranteed to retain all WAL entries that are required for Debezium even during Debezium outages." They tell you to "closely monitor replication slots" to avoid too much disk consumption, and catalog bloat if a slot stays unused for too long. When you remove a connector, they say to drop the slot as the database administrator.
I cannot give you a "disk fills in N hours" figure, and nobody can without your write rate. I did not measure one. The shape is simple: the more you write, the faster an orphaned slot grows.
How do I limit the damage?
Watch pg_replication_slots, cap retention with max_slot_wal_keep_size, and on Postgres 18 consider idle_replication_slot_timeout. Both caps are off by default. The trade-off is real: a cap protects your disk, and a slot that falls too far behind is invalidated, so its reader needs a fresh copy.
PostgreSQL 18 docs, replication settings and the pg_replication_slots view, read 3 Oct 2026. · aliteq research
What each control does, from the docs:
max_slot_wal_keep_size. The default is -1, which lets slots "retain an unlimited amount of WAL files". Set a size and a slot that falls behind by more than that may lose the WAL it needs. This is the hard stop for disk.
idle_replication_slot_timeout. It invalidates slots that stay inactive longer than the duration you set. The default is zero, which turns it off. It is in the PostgreSQL 18 docs. I checked the 17 and 16 pages and it is not there, so do not assume it on an older server.
The pg_replication_slots view. Look at active (is anything reading it), restart_lsn (the oldest WAL the slot may still need), inactive_since, wal_status and safe_wal_size. The wal_status values are reserved, extended, unreserved and lost. Treat extended as the yellow light.
Drop what you do not need. The docs' own advice, in one line.
Debezium adds a trap of its own. Its docs describe a low-traffic database on the same server as a busy one. Slots work per database, but "WAL is shared by all databases", so the WAL "tends to grow until an event is emitted by the database for which Debezium is capturing changes". Their fix is a heartbeat, set with heartbeat.interval.ms. Their docs also say not to use slot.drop.on.stop outside testing.
What does Debezium do?
Debezium is an open-source CDC tool. Its PostgreSQL connector does an initial snapshot of your tables, then streams committed inserts, updates and deletes as events. It is most commonly deployed on Apache Kafka Connect, and the docs also describe Debezium Server and an embedded library.
Debezium reads from a replication slot using Postgres's pgoutput plugin (native since Postgres 10) or decoderbufs. That puts everything above on its plate. It needs a slot and a publication, and if you run several connectors on one server, each needs its own, because sharing one "risk[s] data loss".
Three things to know before you pick it:
At-least-once delivery. After a fault, "the connector might emit some duplicate change events". Build your consumers to handle repeats.
A dedicated user. The docs say to create a dedicated replication user with specific privileges rather than "excessive privileges". This is the security habit I care about most. The PostgreSQL docs say the replication role needs the REPLICATION attribute (or superuser), plus SELECT on the published tables for the initial copy.
It is infrastructure. Kafka Connect "operates as a separate service besides the Kafka broker". You are adding something to run, monitor and upgrade.
How does Supabase fit in?
Supabase offers two related things. Realtime's Postgres Changes pushes row changes to browsers and apps. Database replication, including a managed product called Pipelines, copies data to other systems. Supabase's docs say Realtime is for broadcasting updates to clients, not for keeping a copy of your database elsewhere.
Realtime Postgres Changes. You subscribe to a schema, table or event from the client library, and filters run server side. For an app, this is the easiest CDC you will meet. It comes with caveats that read like a security review:
To get the old values on an update or delete, set replica identity full on the table.
The docs say "RLS policies are not applied to DELETE statements, because there is no way for Postgres to verify that a user has access to a deleted record". Do not put anything secret in a payload you assume Row Level Security will hide on delete. For the RLS basics, see our fix for the new row violates row-level security policy error.
Each change is authorized for every subscriber. One change with 100 subscribers means 100 checks, and changes run on one thread to keep order. Supabase says that past roughly 3,000 concurrent subscribers on the same changes, use Broadcast instead.
Replication and Pipelines. Supabase lists three methods: read replicas, Pipelines and manual replication. Pipelines is described as "a managed CDC product" that "uses Postgres logical replication". At the time I read the docs it was in public alpha, with BigQuery, ClickHouse, DuckLake and Snowflake destinations, all also alpha. Manual replication connects tools such as Airbyte, Estuary, Fivetran, Materialize, Stitch or AWS DMS. Supabase's docs repeat the slot warning: slots retain WAL "until their consumers confirm that they no longer need it", and if Postgres removes WAL first, "the slot can become unusable and the consumer may need a new initial copy".
Alpha means the behavior can change. I would not put an alpha product in the path of data you cannot re-create.
When do you not need CDC?
Most apps do not. If you need a fresh list in a UI, a normal query works. If you need to react to a payment, a webhook from the provider is simpler. If you only need analytics queries off the production database, a read replica may do. CDC earns its keep when many systems need the same changes in order.
Run through these first:
One consumer, low volume, a few minutes of lag is fine? Poll a table with an updated_at column, and add a soft-delete flag if you need deletes.
A change should trigger an action in the same transaction? A database trigger is simple and correct, if you keep it small.
You only need a reporting copy inside Supabase? Read replicas are a separate option in their docs.
A browser needs live updates? Realtime does it without you running anything.
Several systems need every change, in order, with deletes? Now log-based CDC is the right tool.
The test I use: can you name who will watch the slot? If not, you are not ready to run one.
Is CDC a security risk?
It widens your attack surface in specific ways. A replication role can read published data, tooling stores credentials, change events copy sensitive columns into new systems, and a forgotten slot is an availability risk. None of this is a reason to avoid it. It is a reason to scope it.
The controls, from the docs:
Least privilege. Debezium recommends a dedicated replication user. PostgreSQL requires REPLICATION and LOGIN, and SELECT on published tables.
Publish only what you need. Publications can limit tables, change types and, per the Postgres docs, columns and rows. A column you never publish cannot leak downstream.
Row security. The docs say that without SUPERUSER and BYPASSRLS, publisher row security policies can execute for the replication role. Check how that interacts with your policies instead of assuming.
Privileges are checked once. The docs say privileges "are only checked once at the start of a replication connection and are not re-checked as each change record is read". Revoking access is not instant for a stream already open. Drop the connection.
Treat destinations as production. A warehouse that receives every change now holds your customer data. It needs the same controls.
Name the consumers and why a query, webhook or read replica is not enough.
A dedicated replication user with only the privileges the tool needs.
List the tables and change types. Check each has a primary key or a replica identity.
Set max_slot_wal_keep_size, and on Postgres 18 consider idle_replication_slot_timeout. Alert on inactive slots.
Write down who drops the slot when the pipeline is retired.
Quick answers
What does CDC stand for and what does it do?
CDC stands for change data capture. It turns each insert, update and delete in a database into an event that other systems can read, usually in the order the changes were committed. It replaces repeated full-table copies with a small, current stream of changes.
Is log-based CDC better than triggers?
It depends on what you can accept. Log-based capture runs after commit, outside your write path, so it does not slow your app. Triggers run in the same transaction as the write, which gives tight correctness but adds work to every write. Log-based costs you a reader to operate and watch.
What happens if a Postgres replication slot is abandoned?
Postgres keeps the WAL and catalog rows the slot may still need, so storage grows. The PostgreSQL docs warn that in extreme cases the database can shut down to prevent transaction ID wraparound. Drop slots you no longer need, and cap retention with max_slot_wal_keep_size.
Does Supabase Realtime use CDC?
Supabase's Realtime Postgres Changes feature streams table changes to clients, and its docs say Realtime also uses Postgres changes. Supabase's separate Pipelines product is described as managed CDC to other systems and was in public alpha when I read the docs. Check the current status before relying on it.
Do I need a primary key for CDC in Postgres?
For updates and deletes to be published, the table needs a replica identity. By default that is the primary key. REPLICA IDENTITY FULL uses the whole row, but the PostgreSQL docs say it should only be a fallback. Inserts work regardless.
Will CDC give me every change exactly once?
Not by default. A logical slot saves its position at checkpoints, so after a crash recent changes can be sent again. Debezium describes at-least-once delivery. Design your consumer so processing the same event twice does no harm.