Postgres CDC: Every Change Data Capture Method Compared

Intergalactic Data Labs

—

—

11 min read

Table of contents

Summarize

Postgres offers five ways to capture changes, and only one of them sees every insert, update, and delete without changing your application. As of October 2026 those are triggers, LISTEN and NOTIFY, timestamp polling, logical decoding of the write-ahead log, and tools built on that log such as Debezium, Fivetran, PeerDB, and Filament. By the end of this guide you will have logical replication CDC running and verified.

You need

Value

Postgres version

10 or later for pgoutput, 18.6 current

Server parameter

wal_level = logical, restart required

Role

REPLICATION attribute plus SELECT on tables

Tables

Primary key on every captured table

Worked example

Filament Postgres source, beta

Time to complete

About 45 minutes

Versions come from the Postgres 18 docs and Debezium's connector page, parameters and roles from the logical replication configuration page.

How does Postgres change data capture work?

Every Postgres CDC method is a way of reading, or imitating, the write-ahead log. Change data capture means recording each row change as an event you can replay elsewhere. Postgres already records every committed change in its write-ahead log, so the real question is how you get those records out.

The Postgres docs define the native path. "Logical decoding is the process of extracting all persistent changes to a database's tables into a coherent, easy to understand format." A replication slot marks how far a consumer has read. The same page notes that slots are crash-safe and that only one receiver may consume a slot at a time.

Three pieces work together. A publication names the tables and operations to publish, a slot holds the stream position and the WAL the consumer still needs, and an output plugin such as pgoutput or wal2json formats the changes.

The other methods avoid the log and pay for it. Triggers run inside every transaction. NOTIFY payloads are capped under 8000 bytes and reach only sessions listening at that moment. Polling an updated_at column cannot see deletes, as the Filament source docs put it, "Hard deletes are not observable through an update watermark."

Method

Sees deletes

Needs wal_level logical

Survives consumer downtime

Touches the app

Row triggers to audit table

Yes

No

Yes

Adds write cost

LISTEN and NOTIFY

Only if sent

No

No

Yes, app sends

Timestamp or xmin polling

No

No

Yes, re-query

Needs cursor column

Logical decoding, pgoutput

Yes

Yes

Yes, slot holds WAL

No

Logical decoding, wal2json

Yes

Yes, plus plugin listing

Yes, slot holds WAL

No

Tool on top of a slot

Yes

Yes

Yes, slot plus checkpoint

No

Row sources are the CREATE TRIGGER, LISTEN, system columns, and logical decoding pages.

Which Postgres CDC method should you pick?

Pick logical decoding unless you cannot change wal_level, and put a tool on top of it. Triggers still win for an audit trail inside the same database. Polling wins when deletes do not matter, and NOTIFY wins for waking a worker, never for moving data.

One disclosure before the worked example. Galaxy builds Filament, the tool used in the steps below, and its Postgres source is beta, which the docs define as runs end to end but not verified for every environment. The Postgres steps transfer to Debezium, PeerDB, or dlt unchanged.

How to set up Postgres CDC with Filament, step by step

Eight steps take you from a stock Postgres to a verified change stream feeding a typed replica. Steps one to four are plain Postgres and apply to every tool. Steps five to eight are the Filament side.

1. Set wal_level to logical and restart

Run this on the source, then restart, because the docs say this parameter can only be set at server start.

ALTER SYSTEM SET wal_level = logical;
-- restart the server, then confirm

ALTER SYSTEM SET wal_level = logical;
-- restart the server, then confirm

ALTER SYSTEM SET wal_level = logical;
-- restart the server, then confirm

The default of 10 for max_replication_slots and max_wal_senders is enough for one pipeline, per the replication settings page. Managed services rename the switch, see the breakage section.

2. Create a replication role

The security page requires the REPLICATION attribute and SELECT on every published table.

CREATE ROLE filament_cdc WITH LOGIN REPLICATION PASSWORD 'change-me';
GRANT pg_read_all_data TO filament_cdc;
GRANT CREATE ON DATABASE app TO

CREATE ROLE filament_cdc WITH LOGIN REPLICATION PASSWORD 'change-me';
GRANT pg_read_all_data TO filament_cdc;
GRANT CREATE ON DATABASE app TO

CREATE ROLE filament_cdc WITH LOGIN REPLICATION PASSWORD 'change-me';
GRANT pg_read_all_data TO filament_cdc;
GRANT CREATE ON DATABASE app TO

The CREATE grant lets the tool manage its own publication. Add a pg_hba.conf entry allowing replication connections for this role.

3. Confirm primary keys and replica identity

Every captured table needs a primary key. Filament rejects keyless tables in CDC mode, and Postgres refuses updates and deletes on published tables without a usable replica identity.

SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
  AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)

SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
  AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)

SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
  AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)

Any table that comes back needs a key before step six. Leave replica identity at DEFAULT unless a table carries large TOAST columns, in which case set it to FULL.

4. Decide who owns the publication

Filament creates a publication named filament by default and keeps its table list current. If a DBA prefers to provision it, create it now and pass manage_publication=false later.

CREATE PUBLICATION filament FOR TABLE orders, customers
  WITH (publish = 'insert, update, delete')

CREATE PUBLICATION filament FOR TABLE orders, customers
  WITH (publish = 'insert, update, delete')

CREATE PUBLICATION filament FOR TABLE orders, customers
  WITH (publish = 'insert, update, delete')

Scope it to the tables you need, which PeerDB's docs also recommend.

5. Deploy Filament with the PostgreSQL datastore

The source docs state that CDC runs need a datastore with durable replication-stream admission and that the PostgreSQL datastore provides it. That means a deployed Filament rather than the local SQLite context. The Helm chart is the recommended production path, and the Render Blueprint is the one-click option.

helm upgrade --install filament oci://ghcr.io/galaxy-io/charts/filament \
  --set-string persistence.postgresql.dsn="postgresql://filament:...@db:5432/filament" \
  --set-string secrets.datastore.encryptionKey="$(openssl rand -base64 32)" \
  --set-string eventBus.nats.url="nats://nats:4222"
helm upgrade --install filament oci://ghcr.io/galaxy-io/charts/filament \
  --set-string persistence.postgresql.dsn="postgresql://filament:...@db:5432/filament" \
  --set-string secrets.datastore.encryptionKey="$(openssl rand -base64 32)" \
  --set-string eventBus.nats.url="nats://nats:4222"
helm upgrade --install filament oci://ghcr.io/galaxy-io/charts/filament \
  --set-string persistence.postgresql.dsn="postgresql://filament:...@db:5432/filament" \
  --set-string secrets.datastore.encryptionKey="$(openssl rand -base64 32)" \
  --set-string eventBus.nats.url="nats://nats:4222"

Then point the CLI at it, per the CLI guide.

curl -fsSL https://getgalaxy.io/filament/install | sh
filament context add production --server

curl -fsSL https://getgalaxy.io/filament/install | sh
filament context add production --server

curl -fsSL https://getgalaxy.io/filament/install | sh
filament context add production --server

6. Create the CDC source and the sink

Connector fields become flags with a source- or sink- prefix, so the replication field becomes --source-replication. The DSN is stored by the deployment's secret provider.

filament source create app-db \
  --source-connector postgres \
  --source-connection-method url \
  --source-dsn 'postgresql://filament_cdc:change-me@db.internal:5432/app' \
  --source-replication cdc

filament sink create replica \
  --sink-connector postgres \
  --sink-connection-method url \
  --sink-dsn 'postgresql://filament:change-me@replica.internal:5432/analytics'

filament source

filament source create app-db \
  --source-connector postgres \
  --source-connection-method url \
  --source-dsn 'postgresql://filament_cdc:change-me@db.internal:5432/app' \
  --source-replication cdc

filament sink create replica \
  --sink-connector postgres \
  --sink-connection-method url \
  --sink-dsn 'postgresql://filament:change-me@replica.internal:5432/analytics'

filament source

filament source create app-db \
  --source-connector postgres \
  --source-connection-method url \
  --source-dsn 'postgresql://filament_cdc:change-me@db.internal:5432/app' \
  --source-replication cdc

filament sink create replica \
  --sink-connector postgres \
  --sink-connection-method url \
  --sink-dsn 'postgresql://filament:change-me@replica.internal:5432/analytics'

filament source

7. Create the pipeline in merge mode

A CDC route carries no per-resource read mode, and its write mode is append or merge. Merge keeps a current replica, so deletes remove rows, per the replication modes page. The flags below follow the documented pipeline command with the CDC values.

filament pipeline create orders-cdc \
  --source app-db \
  --sink replica \
  --resources orders,customers \
  --sync-mode cdc \
  --write-mode

filament pipeline create orders-cdc \
  --source app-db \
  --sink replica \
  --resources orders,customers \
  --sync-mode cdc \
  --write-mode

filament pipeline create orders-cdc \
  --source app-db \
  --sink replica \
  --resources orders,customers \
  --sync-mode cdc \
  --write-mode

Choose append instead for an immutable history where deletes stay as events.

8. Run the first cycle, then schedule it

The first run creates the slot with an exported snapshot and reads each table through it, so, in the source docs' words, "the baseline and the change stream share one consistent point."

filament run orders-cdc
filament run ls

filament run orders-cdc
filament run ls

filament run orders-cdc
filament run ls

Each later run is a bounded catch-up from the last committed position to the current WAL position. A cron schedule in the web app's pipeline settings keeps the replica current without a resident process.

How to verify Postgres CDC worked

One query on the source and one on the destination prove the pipeline is healthy. Start with the slot, using the pg_replication_slots view.

SELECT slot_name, plugin, active, wal_status, safe_wal_size,
       pg_current_wal_lsn() - confirmed_flush_lsn AS lag_bytes
FROM

SELECT slot_name, plugin, active, wal_status, safe_wal_size,
       pg_current_wal_lsn() - confirmed_flush_lsn AS lag_bytes
FROM

SELECT slot_name, plugin, active, wal_status, safe_wal_size,
       pg_current_wal_lsn() - confirmed_flush_lsn AS lag_bytes
FROM

A wal_status of reserved and a small lag_bytes after a run means the consumer is keeping up. Then make a change and run another cycle.

UPDATE orders SET status = 'shipped' WHERE id = 42;
DELETE FROM customers WHERE id = 7

UPDATE orders SET status = 'shipped' WHERE id = 42;
DELETE FROM customers WHERE id = 7

UPDATE orders SET status = 'shipped' WHERE id = 42;
DELETE FROM customers WHERE id = 7

Run filament run orders-cdc again and query the replica. Row 42 should carry the new status and customer 7 should be gone. Columns prefixed _filament_ record the operation and source position for each change.

What to do when Postgres CDC breaks

Five failures cover almost every CDC incident.

The slot is filling the disk

Postgres warns that "Replication slots persist across crashes and know nothing about the state of their consumer(s)." Cap retention with max_slot_wal_keep_size, drop slots nobody consumes, and on Postgres 18 set idle_replication_slot_timeout per the replication settings.

ALTER SYSTEM SET max_slot_wal_keep_size = '10GB';
SELECT pg_reload_conf();
SELECT pg_drop_replication_slot('old_slot')

ALTER SYSTEM SET max_slot_wal_keep_size = '10GB';
SELECT pg_reload_conf();
SELECT pg_drop_replication_slot('old_slot')

ALTER SYSTEM SET max_slot_wal_keep_size = '10GB';
SELECT pg_reload_conf();
SELECT pg_drop_replication_slot('old_slot')

wal_level is not logical on a managed service

Each provider hides the parameter behind its own switch.

Service

Setting

Restart

Amazon RDS and Aurora

rds.logical_replication = 1

Yes

Google Cloud SQL

cloudsql.logical_decoding = on

Yes

Azure Flexible Server

wal_level = logical

Yes

Supabase

Already logical, direct connection only

No

Neon

Enable in project settings, cannot revert

Yes

Per the RDS, Cloud SQL, Azure, Supabase, and Neon docs.

Updates or deletes fail on the publisher

The error means a published table lacks a usable replica identity. Add a primary key, or as a last resort set the identity to FULL, which the ALTER TABLE docs describe as recording the old values of all columns.

ALTER TABLE events REPLICA IDENTITY FULL
ALTER TABLE events REPLICA IDENTITY FULL
ALTER TABLE events REPLICA IDENTITY FULL
The slot shows wal_status lost

A lost slot cannot be replayed. Drop it and let the next run rebuild from a fresh snapshot.

wal2json is refused after a minor upgrade

Postgres 18.6 and the matching minor releases added output_plugin_libraries, which defaults to pgoutput and test_decoding only, per the 18.6 release notes. Add wal2json to that list and reload. Pipelines on pgoutput are unaffected.

Other ways to do Postgres CDC and what they cost you

Every tool below reads the same slot, so the differences are deployment, pricing unit, and what happens around the stream. Licenses come from each project's LICENSE file, not its README badge.

Tool

Plugin

Deployment

License or pricing

Documented limit

Debezium

pgoutput or decoderbufs

Kafka Connect or Debezium Server

Apache 2.0

No DDL events, no generated columns

PeerDB

pgoutput

Self-host or ClickHouse Cloud

AGPLv3

Maintained paths are Postgres to ClickHouse and Postgres

Airbyte

pgoutput

Self-host or Cloud

ELv2

One destination per CDC source

Fivetran

pgoutput

SaaS

Per monthly active row

Needs its own slot, full dump first

Estuary Flow

Publication based

SaaS

0.50 dollars per GB plus 100 per connector

Needs a watermarks table

dlt

pgoutput

Python library

Apache 2.0

No scd2 merge strategy

ingestr

pgoutput

Go CLI

FSL-1.1

Catch-up by default, stream with a flag

AWS DMS

test_decoding or pglogical

Managed instance

Per instance hour

FULL identity unsupported with pglogical

Google Datastream

pgoutput

Managed

Per GiB processed

One slot per stream

Filament

pgoutput

CLI, Helm, Go library

Apache 2.0

TRUNCATE fails the run, needs its datastore

The Filament route fits teams that want a binary they run themselves with CRC32-C verification on each batch, per the integrity docs.

No speed claim appears here on purpose. The public August 2026 benchmark cohort measured full loads only, and the Filament benchmark page says results do not predict incremental or CDC performance.

Next steps

Start with what change data capture is and Postgres logical replication in full. Full load vs incremental vs CDC explains when polling is enough and the CDC tools listicle goes deeper on the alternatives. The Postgres replication benchmark holds the full-load evidence and the zero-downtime migration guide shows CDC doing a cutover. The Filament announcement covers deployment options.

Frequently asked questions

What are the main CDC methods in PostgreSQL?

There are five. Row triggers that write to an audit table, LISTEN and NOTIFY messages sent by the application, and polling a timestamp or xmin column are the three that avoid the log. Logical decoding through pgoutput or wal2json reads the log directly, and CDC tools consume that same log for you. Logical decoding is the only one that sees every insert, update, and delete without touching the application.

Is logical replication the same as CDC?

Not quite. Logical decoding is the Postgres mechanism that turns the write-ahead log into a stream of row changes. Logical replication is the built-in publish and subscribe feature that consumes that stream to copy tables to another Postgres. CDC tools consume the same stream through a replication slot and send the changes to warehouses, lakes, or queues instead.

How do I check if CDC is enabled in Postgres?

Run SHOW wal_level and confirm it returns logical. Then query pg_replication_slots to see whether any logical slot exists and whether its active column is true. A wal_level of replica or minimal means no logical decoding is possible until you change the parameter and restart the server.

What is the difference between pgoutput and wal2json?

pgoutput is the output plugin that ships with Postgres and powers built-in logical replication, so it is always present and speaks a binary protocol. wal2json is a third-party plugin that emits JSON and must be installed separately. Since the August 2026 minor releases it must also be listed in output_plugin_libraries or the server refuses to load it.

What is REPLICA IDENTITY and when do I need FULL?

Replica identity controls which old column values Postgres writes to the log for updates and deletes. The default records the primary key, which is enough for most tools. FULL records the whole old row and is needed when a table has no primary key or a consumer must see unchanged TOAST columns.

How do I stop a replication slot from filling my disk?

Set max_slot_wal_keep_size so an abandoned slot cannot retain unlimited WAL, drop slots you no longer consume with pg_drop_replication_slot, and watch wal_status in pg_replication_slots. Postgres 18 adds idle_replication_slot_timeout, which invalidates slots that sit unused. Tools that emit heartbeats keep the slot moving on quiet databases.

Does Postgres CDC need Kafka?

No. Kafka is a transport some tools chose, not a Postgres requirement. Debezium Server, PeerDB, Estuary, dlt, ingestr, and Filament all read a replication slot and write straight to a destination without a broker. Pick Kafka when many consumers need the same stream.

Can Filament run Postgres CDC on a schedule instead of continuously?

Yes. The Postgres source runs CDC as bounded catch-up cycles, each replaying from the last committed position to the current WAL position and then returning. The docs describe continuous CDC as a re-request loop, so a cron schedule on the pipeline keeps the replica current without a resident process.

More articles

Stay up to date with what we’re building

Stay up to date with what we’re building

Stay up to date with what we’re building

Questions

Answered

FAQ

What does Galaxy do?

What is Filament?

What is enterprise context management?

What does working with Galaxy look like?

How do you handle security and compliance?

Why does Galaxy build in the open?

Own your knowledge stack

Own your knowledge stack

Copyright © 2026 Galaxy. All rights reserved.