Debezium PostgreSQL Connector — CDC in Action
Debezium is a distributed CDC platform that tails PostgreSQL’s Write-Ahead Log and produces a Kafka event for every row change. It runs as a connector inside Kafka Connect, which means you deploy it by POSTing a JSON config to a REST API — no custom code needed. This post walks through setting up a local Debezium stack, registering a connector, and reading the events it produces.
How It Works
1
2
3
4
5
6
7
PostgreSQL WAL (logical replication)
|
v
Debezium Connector (runs inside Kafka Connect)
|
v
Kafka Topics (one per table by default: <prefix>.<schema>.<table>)
Debezium uses PostgreSQL’s logical decoding. When you register a connector:
- It creates a replication slot in PostgreSQL — a cursor that tracks how far Debezium has read in the WAL
- It creates a publication (or uses an existing one) — tells PostgreSQL which tables to include
- On first start, it does an initial snapshot — reads all existing rows and produces them as events with
op: "r"(read) - After the snapshot, it switches to streaming — tailing the WAL in real time for INSERTs, UPDATEs, and DELETEs
The Stack
A minimal local stack needs five services: PostgreSQL (with wal_level=logical), Zookeeper, Kafka, Kafka Connect with the Debezium plugin, and optionally Confluent Control Center for a web UI.
A docker-compose.yml for this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
services:
postgres:
image: postgres:17.6-alpine
environment:
POSTGRES_DB: learn
POSTGRES_PASSWORD: postgres
command: [
"postgres",
"-c", "wal_level=logical",
"-c", "max_replication_slots=4",
"-c", "max_wal_senders=4"
]
ports:
- "5432:5432"
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
zookeeper:
image: confluentinc/cp-zookeeper:7.5.1
environment:
ZOOKEEPER_CLIENT_PORT: 2181
broker:
image: confluentinc/cp-kafka:7.5.1
depends_on: [zookeeper]
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,HOST:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,HOST://localhost:9092
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
connect:
image: confluentinc/cp-server-connect:7.5.1
depends_on: [broker]
ports:
- "8083:8083"
environment:
CONNECT_BOOTSTRAP_SERVERS: broker:29092
CONNECT_REST_PORT: 8083
CONNECT_GROUP_ID: connect-cluster
CONNECT_CONFIG_STORAGE_TOPIC: _connect-configs
CONNECT_OFFSET_STORAGE_TOPIC: _connect-offsets
CONNECT_STATUS_STORAGE_TOPIC: _connect-status
CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: 1
CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: 1
CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: 1
CONNECT_KEY_CONVERTER: org.apache.kafka.connect.storage.StringConverter
CONNECT_VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter
CONNECT_VALUE_CONVERTER_SCHEMAS_ENABLE: "false"
CONNECT_REST_ADVERTISED_HOST_NAME: connect
command:
- bash
- -c
- |
confluent-hub install --no-prompt debezium/debezium-connector-postgresql:2.5.4
/etc/confluent/docker/run
The key PostgreSQL setting is wal_level=logical — without it, the WAL does not contain enough information for logical decoding. The init.sql script creates a sample schema and seeds some rows so there is data to snapshot.
A sample init.sql:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer VARCHAR(255),
product VARCHAR(255),
amount NUMERIC(10,2),
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
email VARCHAR(255),
tier VARCHAR(50) DEFAULT 'standard',
created_at TIMESTAMP DEFAULT now()
);
INSERT INTO orders (customer, product, amount) VALUES
('alice', 'Widget A', 29.99),
('bob', 'Widget B', 49.99),
('carol', 'Widget C', 19.99);
INSERT INTO customers (name, email, tier) VALUES
('Alice', 'alice@example.com', 'premium'),
('Bob', 'bob@example.com', 'standard');
Registering a Connector
Once the stack is running and Kafka Connect is healthy (poll GET http://localhost:8083/ until it responds), register a connector by POSTing its configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
curl -X POST http://localhost:8083/connectors \
-H "Content-Type: application/json" \
-d '{
"name": "learn-cdc-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "postgres",
"database.password": "postgres",
"database.dbname": "learn",
"topic.prefix": "learn",
"plugin.name": "pgoutput",
"slot.name": "learn_cdc_slot",
"publication.autocreate.mode": "all_tables",
"snapshot.mode": "initial",
"tombstones.on.delete": "false",
"key.converter": "org.apache.kafka.connect.storage.StringConverter",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": "false"
}
}'
| Config | What it does |
|---|---|
plugin.name: pgoutput |
Uses PostgreSQL’s built-in logical decoding output plugin |
topic.prefix: learn |
Topics are named <prefix>.<schema>.<table>, e.g. learn.public.orders |
slot.name |
The replication slot name in PostgreSQL — persists even if the connector is deleted |
publication.autocreate.mode: all_tables |
Debezium auto-creates a publication covering all tables |
snapshot.mode: initial |
On first start, snapshot all existing rows, then switch to streaming |
Check the connector is running:
1
curl http://localhost:8083/connectors/learn-cdc-connector/status | jq
Both the connector and its task should show "state": "RUNNING".
Check what Debezium created in PostgreSQL:
1
2
3
4
5
SELECT slot_name, plugin, slot_type, active, restart_lsn
FROM pg_replication_slots;
SELECT * FROM pg_publication;
SELECT * FROM pg_publication_tables;
The replication slot learn_cdc_slot and an auto-created publication dbz_publication covering all tables — these are the two PostgreSQL objects Debezium needs for CDC.
The Initial Snapshot
List topics to see what Debezium created:
1
kafka-topics --bootstrap-server localhost:9092 --list
Topics like learn.public.orders and learn.public.customers appear. Consume from one:
1
2
3
4
5
6
kafka-console-consumer \
--bootstrap-server localhost:9092 \
--topic learn.public.orders \
--from-beginning \
--property print.key=true \
--property key.separator=" | "
Events appear for the 3 seeded rows. Each event has this structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"before": null,
"after": {
"id": 1,
"customer": "alice",
"product": "Widget A",
"amount": 29.99,
"status": "pending",
"created_at": 1715500000000,
"updated_at": 1715500000000
},
"source": {
"version": "2.5.4.Final",
"connector": "postgresql",
"name": "learn",
"ts_ms": 1715500000000,
"snapshot": "first",
"db": "learn",
"schema": "public",
"table": "orders",
"txId": 1234,
"lsn": 12345678
},
"op": "r",
"ts_ms": 1715500000000
}
The Event Envelope
Every Debezium event has this structure:
| Field | What it is |
|---|---|
before |
Row state before the change. null for snapshots and INSERTs. Only populated for UPDATEs/DELETEs when replica identity is FULL |
after |
Row state after the change. null for DELETEs |
source |
Metadata: database, schema, table, transaction ID, WAL position (LSN), whether this was a snapshot |
op |
The operation type |
ts_ms |
Timestamp when Debezium processed the event |
Operation types:
| op | Meaning | When |
|---|---|---|
r |
read | Initial snapshot — existing rows |
c |
create | INSERT after streaming starts |
u |
update | UPDATE |
d |
delete | DELETE (after is null, before has the deleted row if replica identity is FULL) |
The seeded rows appear as op: "r" because the INSERTs in init.sql happened before Debezium connected. When the connector starts with snapshot.mode: initial, it reads all existing rows and publishes them as read events. Changes made after the connector is running are captured from the WAL as c/u/d.
Live Changes
With the consumer still running, open a psql session and make changes:
1
2
3
4
5
6
7
8
9
-- INSERT: produces an op: "c" event
INSERT INTO orders (customer, product, amount)
VALUES ('dave', 'Widget D', 39.99);
-- UPDATE: produces an op: "u" event
UPDATE orders SET status = 'shipped' WHERE id = 1;
-- DELETE: produces an op: "d" event
DELETE FROM orders WHERE id = 3;
Three new events appear on the consumer:
- The INSERT has
"op": "c"withbefore: nullandaftercontaining the new row - The UPDATE has
"op": "u"withbefore: nullandafterwith the updated row —beforeis null because the default replica identity only includes the primary key in WAL records, not the full row - The DELETE has
"op": "d"withafter: null— andbeforeis also null for the same reason
Message Keys
The key printed before the | separator is the table’s primary key, serialized as JSON:
1
{"id":1} | {"before": null, "after": {...}, "op": "u", ...}
Debezium uses the primary key as the Kafka message key, which means all events for the same row go to the same Kafka partition — guaranteeing ordering per row.
What Debezium Reads vs. What the Application Reads
Debezium reads the WAL, not the tables themselves. It opens a replication connection to PostgreSQL (using replication=database in the connection string) and receives a stream of decoded changes. This is the same mechanism PostgreSQL uses for built-in logical replication between database instances. The application’s regular queries are unaffected — Debezium adds no load to the query path. The only overhead is the WAL retention: PostgreSQL must hold WAL segments that the replication slot has not yet consumed.