Change data capture and the outbox, lab
Module 08 described change data capture and the outbox pattern, but could not run them. This lab runs PostgreSQL and Debezium, captures a table, builds an outbox, and then stops a connector to see what the database does with its log.
Try it: pick a change, read the record
Choose an operation on the orders table. The tiles show the change record the lab produced. The checkbox switches the table setting REPLICA IDENTITY FULL.
What it is and why it exists
A service that changes its database and also publishes an event to Kafka has a problem. If it writes twice, one write can succeed while the other fails. Module 08 explains this "dual write" problem and names two answers: change data capture and the outbox pattern. See module 08 for the theory, Kafka Connect, dead-letter topics and the schema registry. This module does not repeat them.
Change data capture means a program reads the database's own change log and publishes each insert, update and delete as a Kafka record. The tool here is Debezium, a set of source connectors for Kafka Connect. The database is PostgreSQL 16. You run both and read the records they produce.
The lab shows four things: what a change record looks like, how an outbox keeps rolled-back work out of Kafka, how the database behaves while a connector is stopped, and one odd result about lag that you should expect.
The one-sentence version. The database log becomes a Kafka topic, in commit order and at least once, and the connector's replication slot decides how much log the database must keep.
Mental model
Think of two ways to learn what happened in a database.
| Way | What you read | What you get |
|---|---|---|
| Direct table capture | The log entries for your business tables, such as orders. | One record per row change, in the shape of the table. The record says what changed in the table, not why. |
| Outbox | The log entries for one extra table, outbox, that the service fills on purpose. | One record per business event that you designed. The event is written in the same transaction as the business change. |
Words used in this module
- Write-ahead log (WAL). The file in which PostgreSQL records every change before it applies it. Capture tools read it.
- Logical decoding. A PostgreSQL feature that turns the WAL into row-level changes. It needs the server setting
wal_level=logical. - Replication slot. A bookmark inside PostgreSQL that remembers how far a reader has confirmed. PostgreSQL keeps all log after that point.
- Publication. A named list of tables whose changes are sent to readers.
- LSN. Log sequence number: a position in the WAL. Each change record carries one.
- Snapshot. When a connector first starts it reads the rows that already exist. Those records have operation code
r.
Use the tiles at the top of the page. Pick each operation and note which of before and after is empty.
How it works internally
The path of one change
- The application commits a transaction. PostgreSQL writes it to the WAL.
- The replication slot lets the connector read decoded changes. The lab uses the output plugin
pgoutput. - The connector sends each change into Kafka. For the table
public.ordersand topic prefixshop, the topic isshop.public.orders. - Later, the connector tells PostgreSQL how far it has safely delivered. Only then can PostgreSQL discard older log.
The change record
The record key is the table's primary key, so all changes to one row go to the same partition in order. The value holds an operation code, a before image, an after image and a source block.
| Operation | Code | before | after |
|---|---|---|---|
| Snapshot read | r | null | the row |
| Insert | c | null | the row |
| Update | u | the old row | the new row |
| Delete | d | the old row | null |
All four rows of this table were seen in the lab, with REPLICA IDENTITY FULL set on the table. After a delete record, the lab topic also held a second record with the same key and a null value. That is a tombstone: for a compacted topic it marks the key for removal.
What the before image holds without REPLICA IDENTITY FULL was not tested. The usual claim is that it is empty or holds only the key columns. unverified
How the outbox is routed
The second connector in the lab uses a transform, a small step that rewrites each record. The transform is Debezium's EventRouter. It turns an outbox row into a plain event. In the lab, the routing went by the column aggregatetype: rows with order landed in the topic outbox.event.order. The message key was the aggregateid column. The value was the payload column. Each message had a header id holding the row's uuid.
Where the event type (OrderCreated or OrderPaid) goes was not visible in the consumer output, and I did not check it. It may be a header. unverified
Why a stopped connector costs disk
A slot holds a position. PostgreSQL may not delete log after that position. While a connector runs, the position advances. While it is stopped, the slot is inactive and the position stays still, so the log grows. Part C of the lab measures this.
Every relevant config
These are the settings that ran in the lab. The lab used the Docker image postgres:16-alpine and the image quay.io/debezium/connect:latest, which contained Debezium 3.7.0.Final and Kafka Connect 4.3.1.
| Where | Setting | Lab value | What it does |
|---|---|---|---|
| PostgreSQL start option | wal_level | logical | Lets PostgreSQL decode the log into row changes. Passed as -c wal_level=logical. |
| Table | REPLICA IDENTITY | FULL on orders | Set with ALTER TABLE orders REPLICA IDENTITY FULL. The lab saw full before images on update and delete. |
| Connector | connector.class | io.debezium.connector.postgresql.PostgresConnector | Selects the PostgreSQL source connector. |
| Connector | plugin.name | pgoutput | The decoding plugin built into PostgreSQL. |
| Connector | database.hostname, port, user, password, dbname | pg, 5432, postgres, lab, postgres | How to reach the database. Written with the prefix database. in the real request (see the lab). |
| Connector | topic.prefix | shop and shopout | Starts every topic name. Topic = prefix, schema, table. |
| Connector | table.include.list | public.orders; public.outbox | Which tables to capture. |
| Connector | slot.name | orders_slot; outbox_slot | Names the replication slot. Give each connector its own. |
| Connector | publication.name | orders_pub; outbox_pub | Names the publication. |
| Connector | converters | JSON, schemas.enable=false | Writes plain JSON without the schema envelope. The outbox connector used a String key converter. |
| Outbox connector | transforms, transforms.outbox.type | outbox; io.debezium.transforms.outbox.EventRouter | Turns outbox rows into routed events. |
| Connect REST | PUT /connectors/orders-cdc/stop | HTTP 204 | Stops the connector; status becomes STOPPED with no tasks. |
| Connect REST | PUT /connectors/orders-cdc/resume | HTTP 202 | Starts it again. |
| Connect cluster | internal topics | cdc-configs, cdc-offsets, cdc-status, factor 3 | Where the Connect worker keeps configs, offsets and status. |
Settings that exist but were not tested
I know of these Debezium settings from memory. I did not run them, and I give no default values. unverified
snapshot.mode: how the first read of existing rows behaves. unverifiedheartbeat.interval.ms: makes an idle connector emit regular records so its slot can advance. unverifiedmax.queue.size: the size of the connector's internal queue. unverifiedpublication.autocreate.mode: whether the connector creates the publication. unverifiedslot.drop.on.stop: whether the slot is dropped when the connector stops. unverifieddecimal.handling.mode: how decimal columns are encoded. unverifiedtombstones.on.delete: whether a tombstone follows each delete record. unverifiedoffset.flush.interval.ms: how often Connect saves source offsets. unverified
Failure scenarios
A stopped or dead connector fills the database disk
- You observe
- After the connector was stopped, 60,000 inserted rows left its slot inactive and 8607 kB behind. Before, it was 23 kB behind.
- Cause
- PostgreSQL keeps all log after the slot's confirmed position, as long as the slot exists. A slot with no reader never lets go.
- Fix
- Monitor each slot's bytes behind (the lab query is in the lab section). Alert on an inactive slot. Drop a slot you will not use again.
An idle table still holds log
- You observe
- The
outbox_slotstayed active, yet it was also 8607 kB behind. Its table had no changes at all. - Cause
- The usual explanation: the slot advances only when the connector sees a change in a captured table, while the log grows from every table in the database. unverified
- Fix
- Heartbeat settings exist for this case. I did not test them. unverified
Lag looks stuck after a resume
- You observe
- After
resume, the topic reached its full end offset of 60007. Over the next 40 seconds the slot stayed about 8.8 MB behind (8813272, then 8852600 bytes). - Cause
- The data was delivered, but the slot's confirmed position did not move. The likely reason is that the connector confirms positions only when it saves its offsets, by default about every 60 seconds. unverified
- Lesson
- Do not trust "bytes behind" alone to say whether data reached Kafka. Check the topic end offset too.
Decimal columns arrive as odd text
- You observe
- The amount 40.00 appeared as
"D6A="and 15.50 as"Bg4="in the JSON. - Cause
- The default decimal handling probably encodes the number as bytes, which the JSON converter shows as base64 text. unverified
- Fix
- A setting named
decimal.handling.modeexists for this. I did not test it. unverified
The event type is missing for consumers
- You observe
- The outbox consumer output showed the key and payload only. The names
OrderCreatedandOrderPaidwere not visible. - Lesson
- Before you build consumers, check where the router puts the event type. I did not. unverified
Hands-on lab
Every command below was run on 5 October 2026. The database and the Connect worker are in the main docker-compose.yml and start only with the cdc profile. This lab needs the main cluster running.
Step 1. Start the stack
cd ~/Desktop/kafka-knowledge-base
docker compose --profile cdc up -d
k() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }
P() { docker exec -i pg psql -U postgres -q -A -t -c "$1"; }
C=http://localhost:8087The service pg is PostgreSQL started with -c wal_level=logical. The service cdc-connect publishes its REST interface on host port 8087.
Step 2. Create the tables
P "CREATE TABLE orders (id int PRIMARY KEY, customer text NOT NULL, amount numeric(10,2) NOT NULL, status text NOT NULL)"
P "CREATE TABLE outbox (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), aggregatetype text NOT NULL, aggregateid text NOT NULL, type text NOT NULL, payload jsonb NOT NULL, created timestamptz NOT NULL DEFAULT now())"
P "ALTER TABLE orders REPLICA IDENTITY FULL"
P "INSERT INTO orders VALUES (1,'ana',40.00,'created')"One row exists before any connector. The connector will snapshot it.
Step 3. Part A: register the connector for the table
curl -s -X POST $C/connectors -H 'Content-Type: application/json' -d '{
"name": "orders-cdc",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"plugin.name": "pgoutput",
"database.hostname": "pg", "database.port": "5432",
"database.user": "postgres", "database.password": "lab",
"database.dbname": "postgres",
"topic.prefix": "shop",
"table.include.list": "public.orders",
"slot.name": "orders_slot",
"publication.name": "orders_pub",
"key.converter": "org.apache.kafka.connect.json.JsonConverter",
"key.converter.schemas.enable": "false",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": "false"
}}'
# wait about 12 seconds
curl -s $C/connectors/orders-cdc/statusAfter about 12 seconds the connector was RUNNING and task 0 was RUNNING.
Step 4. Change the table and read the topic
P "INSERT INTO orders VALUES (2,'ben',15.50,'created')"
P "UPDATE orders SET status='paid' WHERE id=1"
P "DELETE FROM orders WHERE id=2"
k kafka-console-consumer.sh --bootstrap-server kafka-1:19092 --topic shop.public.orders --from-beginning --formatter-property print.key=true --timeout-ms 7000key | op | before | after | source
{"id":1} | r | null | {"id":1,"customer":"ana","amount":"D6A=","status":"created"} | snapshot=last lsn=22182616
{"id":2} | c | null | {"id":2,"customer":"ben","amount":"Bg4=","status":"created"} | snapshot=false lsn=22460936
{"id":1} | u | {... status created} | {... status paid} | snapshot=false lsn=22461128
{"id":2} | d | {... ben ... created} | null | snapshot=false lsn=22461288
{"id":2} | (value null: a tombstone, after the delete record)
Read it in order. The first row is the snapshot of the row that existed before the connector. The delete is followed by a tombstone for the same key. The amount shows as base64 text, as described in the failure scenarios.
Step 5. Part B: register the outbox connector
curl -s -X POST $C/connectors -H 'Content-Type: application/json' -d '{
"name": "outbox",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"plugin.name": "pgoutput",
"database.hostname": "pg", "database.port": "5432",
"database.user": "postgres", "database.password": "lab",
"database.dbname": "postgres",
"topic.prefix": "shopout",
"table.include.list": "public.outbox",
"slot.name": "outbox_slot",
"publication.name": "outbox_pub",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"key.converter": "org.apache.kafka.connect.storage.StringConverter",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": "false"
}}'The connector reached RUNNING.
Step 6. Three transactions
P "BEGIN; INSERT INTO orders VALUES (3,'cy',99.00,'created'); INSERT INTO outbox (aggregatetype,aggregateid,type,payload) VALUES ('order','3','OrderCreated','{\"orderId\":3,\"amount\":99.00}'); COMMIT;"
P "BEGIN; INSERT INTO orders VALUES (4,'di',10.00,'created'); INSERT INTO outbox (aggregatetype,aggregateid,type,payload) VALUES ('order','4','OrderCreated','{\"orderId\":4,\"amount\":10.00}'); ROLLBACK;"
P "BEGIN; UPDATE orders SET status='paid' WHERE id=3; INSERT INTO outbox (aggregatetype,aggregateid,type,payload) VALUES ('order','3','OrderPaid','{\"orderId\":3}'); COMMIT;"
k kafka-topics.sh --bootstrap-server kafka-1:19092 --list | grep -E 'outbox|shop'
k kafka-console-consumer.sh --bootstrap-server kafka-1:19092 --topic outbox.event.order --from-beginning --formatter-property print.key=true --property print.headers=true --timeout-ms 7000
P "SELECT id, status FROM orders ORDER BY id"
P "SELECT count(*) FROM outbox"topics listed (new ones): outbox.event.order, shop.public.orders
outbox.event.order: exactly 2 messages, key "3" for both
value 1: "{\"amount\": 99.00, \"orderId\": 3}"
value 2: "{\"orderId\": 3}"
each with headers including id:<uuid> and __debezium.context.*
nothing for order 4 (rolled back)
orders rows: 1|paid and 3|paid
outbox row count: 2
The rolled-back transaction produced nothing in Kafka. The payload arrived as an escaped JSON string. Both messages for order 3 share a key, so they stay in order on one partition. The flags in the console consumer line that print headers are my addition for this page; the recorded output showed headers, but I did not keep the exact flag. unverified
Step 7. Part C: stop a connector and watch the slot
SLOTS="SELECT slot_name, active, pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) FROM pg_replication_slots"
P "$SLOTS"
curl -s -o /dev/null -w '%{http_code}\n' -X PUT $C/connectors/orders-cdc/stop
curl -s $C/connectors/orders-cdc/status
P "INSERT INTO orders SELECT g,'bulk',1.00,'created' FROM generate_series(1000,60999) g"
P "$SLOTS"before: orders_slot active=t 23 kB behind
outbox_slot active=t 24 kB behind
stop: HTTP 204; status STOPPED; tasks []
after 60,000 inserts:
orders_slot active=f 8607 kB behind
outbox_slot active=t 8607 kB behind
The second slot is active, but its table saw no change. It is still 8607 kB behind.
Step 8. Resume and measure
curl -s -o /dev/null -w '%{http_code}\n' -X PUT $C/connectors/orders-cdc/resume
# run the slot query again over 40 seconds
k kafka-get-offsets.sh --bootstrap-server kafka-1:19092 --topic shop.public.ordersresume: HTTP 202 bytes behind over 40 s: 8813272 -> 8852600 (about 8.8 MB, not falling) topic shop.public.orders end offset: 60007 (7 + 60,000)
All data reached Kafka, yet the slot did not move within 40 seconds. The end offset command shown above is my wording for how to read it; the recorded value is the one printed. unverified
Step 9. Shut down
docker compose --profile cdc downCommon mistakes and misconceptions
| People think | What is true |
|---|---|
| Change data capture adds no load or risk to the database. | The slot makes the database keep its log. A stopped connector filled 8.6 MB for 60,000 small rows in the lab. |
| A stopped connector is harmless. | Its slot stays and PostgreSQL keeps log for it. |
| Rolled-back work may still reach Kafka. | Only committed changes are published. Order 4 never appeared. |
| A delete is one record. | The lab showed a delete record and then a tombstone with a null value. |
| The numbers arrive as written in the table. | Decimals appeared as base64 text with the JSON converter. |
| Bytes behind tells you if data reached Kafka. | After a resume, the topic was complete while the slot looked stuck. |
| An idle outbox table costs nothing. | Its slot was 8607 kB behind while the table saw no change. |
| Capture removes duplicates. | Delivery is at least once. Consumers must ignore repeats. See module 08. |
Staff-level questions
Outbox or direct table capture?
Direct capture is simple and needs no change in the application, but the records follow the table's shape. A column rename changes what consumers see. An outbox publishes events that you designed, with a stable meaning, written in the same transaction as the business change. The lab showed the outbox also drops rolled-back work.
For events that other teams consume, prefer the outbox. For copying data to a store you own, direct capture is fine. Module 08 gives the same advice.
What ordering does a consumer get?
The changes of one table are read from the log in commit order. The record key is the primary key, so changes to one row share a partition and stay ordered. Across different rows, or different topics, there is no order. In the lab, both events for order 3 had key "3".
I did not test what happens to ordering when a table has many partitions in the topic or when a key changes. unverified
Can consumers see duplicates? When?
Yes. Delivery is at least once. After a connector restart it may resend records from its last saved offset. I did not force a duplicate in the lab. unverified
Each outbox message carried an id header with the row's uuid. A consumer stores the ids it has handled and skips a repeat. Make the check and the effect one atomic step.
A column is added to the table. What happens?
I did not test a schema change. The general risk is that records change shape under running consumers, so you need a plan: add columns with defaults, update consumers first, and keep a schema registry in the path (module 08). unverified
An outbox avoids this. Its payload is a document that you version yourself.
The table has a billion rows. How do you start the connector?
The lab snapshot covered one row, so I cannot give timings. The first start reads all existing rows, tagged r, before it streams changes. It can take a long time and load the database. Setting snapshot.mode exists to change this behaviour. I did not test it. unverified
Plan it: test on a copy, run it in a quiet period, and watch the slot, because the log grows while the snapshot runs. unverified
How do you protect the database from slot lag, and what do you do with deletes on a compacted topic?
Slots: measure bytes behind for every slot with the lab query, alert on inactive slots and growing lag, set an owner for each connector, and drop slots for retired connectors. Remember from the lab that a resumed connector may look behind for a while, so also check the topic's end offset.
Deletes: a delete record is followed by a tombstone with the same key and a null value. On a compacted topic, the tombstone lets Kafka eventually remove every record for that key. Without compaction, the tombstone does nothing special, and the topic keeps the full history. Whether to keep tombstones is a setting, tombstones.on.delete, which I did not test. unverified
Self-test
1. Which PostgreSQL setting does capture need?
wal_level=logical.
2. What operation code does a snapshot read have?
r. Insert is c, update u, delete d.
3. Which of before and after is null on a delete?
The after image. The before image holds the old row.
4. What is the record key for the orders topic?
The primary key, for example {"id":1}.
5. Why did order 4 not appear in Kafka?
Its transaction rolled back. Only committed changes are published.
6. Why does a stopped connector put the database at risk?
Its slot becomes inactive but stays. PostgreSQL keeps all log after the slot's position, so disk use grows.
7. After resume, the topic was complete but the slot looked 8.8 MB behind. What does that show?
Bytes behind can lag the real state. Check the topic end offset as well. The likely cause, a delayed offset flush, was not verified.
8. How does a consumer ignore repeated outbox events?
It records the id header of each handled event and skips any id it has seen.