Kafka / 13 Change data capture lab
All modules
Module 13 · Depth lab · Kafka 4.3

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.

offset 0

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.

offset 1

Mental model

Think of two ways to learn what happened in a database.

WayWhat you readWhat you get
Direct table captureThe 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.
OutboxThe 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

Use the tiles at the top of the page. Pick each operation and note which of before and after is empty.

offset 2

How it works internally

The path of one change

  1. The application commits a transaction. PostgreSQL writes it to the WAL.
  2. The replication slot lets the connector read decoded changes. The lab uses the output plugin pgoutput.
  3. The connector sends each change into Kafka. For the table public.orders and topic prefix shop, the topic is shop.public.orders.
  4. 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.

OperationCodebeforeafter
Snapshot readrnullthe row
Insertcnullthe row
Updateuthe old rowthe new row
Deletedthe old rownull

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.

offset 3

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.

WhereSettingLab valueWhat it does
PostgreSQL start optionwal_levellogicalLets PostgreSQL decode the log into row changes. Passed as -c wal_level=logical.
TableREPLICA IDENTITYFULL on ordersSet with ALTER TABLE orders REPLICA IDENTITY FULL. The lab saw full before images on update and delete.
Connectorconnector.classio.debezium.connector.postgresql.PostgresConnectorSelects the PostgreSQL source connector.
Connectorplugin.namepgoutputThe decoding plugin built into PostgreSQL.
Connectordatabase.hostname, port, user, password, dbnamepg, 5432, postgres, lab, postgresHow to reach the database. Written with the prefix database. in the real request (see the lab).
Connectortopic.prefixshop and shopoutStarts every topic name. Topic = prefix, schema, table.
Connectortable.include.listpublic.orders; public.outboxWhich tables to capture.
Connectorslot.nameorders_slot; outbox_slotNames the replication slot. Give each connector its own.
Connectorpublication.nameorders_pub; outbox_pubNames the publication.
ConnectorconvertersJSON, schemas.enable=falseWrites plain JSON without the schema envelope. The outbox connector used a String key converter.
Outbox connectortransforms, transforms.outbox.typeoutbox; io.debezium.transforms.outbox.EventRouterTurns outbox rows into routed events.
Connect RESTPUT /connectors/orders-cdc/stopHTTP 204Stops the connector; status becomes STOPPED with no tasks.
Connect RESTPUT /connectors/orders-cdc/resumeHTTP 202Starts it again.
Connect clusterinternal topicscdc-configs, cdc-offsets, cdc-status, factor 3Where 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. unverified
  • heartbeat.interval.ms: makes an idle connector emit regular records so its slot can advance. unverified
  • max.queue.size: the size of the connector's internal queue. unverified
  • publication.autocreate.mode: whether the connector creates the publication. unverified
  • slot.drop.on.stop: whether the slot is dropped when the connector stops. unverified
  • decimal.handling.mode: how decimal columns are encoded. unverified
  • tombstones.on.delete: whether a tombstone follows each delete record. unverified
  • offset.flush.interval.ms: how often Connect saves source offsets. unverified
offset 4

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_slot stayed 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.mode exists 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 OrderCreated and OrderPaid were not visible.
Lesson
Before you build consumers, check where the router puts the event type. I did not. unverified
offset 5

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:8087

The 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/status

After 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 7000
Output, reduced to the fields that matter
key | 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"
Results
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"
Results
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.orders
resume: 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 down
offset 6

Common mistakes and misconceptions

People thinkWhat 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.
offset 7

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

offset 8

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.