CDC with Debezium skill for Codex
Every insert, update and delete, in order. Codex configures Debezium connectors with secrets kept out of JSON, chooses snapshot modes deliberately, and flattens events without silently dropping deletes. It wires the transactional outbox through the EventRouter SMT, re-snapshots tables with signal-table incremental snapshots, and puts guards on replication slots so a dead connector cannot fill the primary's disk with WAL.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cdc-debezium Installs to ~/.agents/skills/cdc-debezium/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cdc-debezium The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cdc-debezium Installs to .agents/skills/cdc-debezium/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cdc-debezium.zipunzip -o cdc-debezium.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cdc-debezium PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $cdc-debezium, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up and operates change data capture with Debezium 2.x and 3.x on Kafka Connect or Debezium Server - PostgreSQL logical replication with pgoutput, MySQL binlog, connector JSON, snapshot modes and incremental snapshots via signal tables, event envelope and ExtractNewRecordState, the transactional outbox with the EventRouter SMT, replication slot and WAL monitoring, and idempotent downstream apply. Use when the user wants to stream database changes to Kafka or a warehouse, implement the outbox pattern, re-snapshot a table, or debug a connector that stalls, misses deletes or fills the database disk with WAL.
Use it when
- Streaming database inserts, updates and deletes to Kafka or a warehouse
- Implementing a transactional outbox with Debezium
- Re-snapshotting a table or recovering a stalled connector
- Sinks are missing deletes or receiving partial before-images
Use something else when
- You need general Kafka producer and consumer tuning: use kafka-streaming
- The question is outbox and inbox design across services, not Debezium wiring: use outbox-pattern
- A daily batch extract with updated_at is enough: use etl-pipeline-design
How to check the result
A test row's insert, update and delete arrive as c, u and d events and the sink reflects all three, while retained WAL on the replication slot stays under the alert threshold.
The mistake it prevents
Leaving an inactive replication slot behind, which keeps WAL forever until the primary database runs out of disk.
Done well means: every insert, update and delete in the selected tables reaches consumers in per-key order, the source database is never put at risk by a stuck replication slot, snapshots and re-snapshots are planned, and consumers apply events idempotently.
Before configuring
- Read
AGENTS.md, existing connector configs (connectors/*.json, Helm values, StrimziKafkaConnectorresources) and the Kafka Connect worker config. - Confirm versions: Debezium (3.x needs Java 17+), the database version, and whether the database is managed (RDS, Cloud SQL, Azure) which changes how you enable logical replication.
- Decide the runtime:
Runtime Use when Kafka Connect + Debezium connector Kafka is the backbone; most common Debezium Server Sink is Kinesis, Pub/Sub, Pulsar, Redis, HTTP without running Kafka Embedded engine (Java) CDC inside one application; you own offsets and restarts - Changing database parameters, creating replication users, slots or publications on a production database requires the DBA’s or user’s explicit approval. Some parameters need a restart.
PostgreSQL prerequisites
-- postgresql.conf (or managed-service parameter group): wal_level = logical,
-- max_replication_slots and max_wal_senders above current use, max_slot_wal_keep_size set (PG 13+)
create role debezium with login replication password '...';
grant select on public.orders, public.customers to debezium;
create publication dbz_shop for table public.orders, public.customers;
alter table public.orders replica identity full; -- only where consumers need full before-images
With the default replica identity, update and delete events carry only primary key columns in
before, and unchanged TOASTed columns arrive as a placeholder
(__debezium_unavailable_value). Use REPLICA IDENTITY FULL on tables where that matters,
knowing it increases WAL volume.
Connector config (PostgreSQL)
{
"name": "shop-pg",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "pg.internal",
"database.port": "5432",
"database.user": "debezium",
"database.password": "${file:/opt/secrets/pg.properties:password}",
"database.dbname": "shop",
"topic.prefix": "shop",
"plugin.name": "pgoutput",
"slot.name": "debezium_shop",
"publication.name": "dbz_shop",
"publication.autocreate.mode": "disabled",
"table.include.list": "public.orders,public.customers",
"snapshot.mode": "initial",
"heartbeat.interval.ms": "10000",
"signal.data.collection": "public.debezium_signal",
"decimal.handling.mode": "string",
"tombstones.on.delete": "true"
}
}
- Topics are named
<topic.prefix>.<schema>.<table>, keyed by primary key, so per-row order is preserved within a partition. Tables without a primary key needmessage.key.columns. - Secrets via a config provider (
config.providers=fileon the worker); never inline passwords in JSON committed to git. topic.prefixandslot.nameare identity: changing them later means a new slot and a full new snapshot.
MySQL differences: binlog_format=ROW, binlog_row_image=FULL, GTIDs on, binlog retention
longer than any expected connector downtime, a unique database.server.id, and
schema.history.internal.kafka.topic (never delete or compact-away that topic).
Snapshots
snapshot.mode | Behaviour | Use |
|---|---|---|
initial (default) | Snapshot existing rows, then stream | New pipelines |
no_data | Stream only from now on (formerly never/schema_only) | History loaded another way |
when_needed | Snapshot if offsets are missing or no longer valid | Self-healing after long outages |
initial_only | Snapshot then stop | One-off copies |
Re-snapshot one table without restarting, using an incremental snapshot signal:
create table public.debezium_signal (id varchar(42) primary key, type varchar(32) not null, data varchar(2048));
insert into public.debezium_signal (id, type, data) values
('resnap-orders-2025-09-30', 'execute-snapshot',
'{"data-collections": ["public.orders"], "type": "incremental"}');
Incremental snapshots read in chunks interleaved with streaming, so they do not block writes. The signal table must be in the publication / include list.
Event shape and flattening
Raw events carry before, after, source (LSN, txId, ts_ms), op (c create, u update,
d delete, r snapshot read) and ts_ms. Most sinks want the flat row:
"transforms": "unwrap",
"transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
"transforms.unwrap.delete.tombstone.handling.mode": "rewrite",
"transforms.unwrap.add.fields": "op,source.ts_ms,source.lsn"
rewrite keeps deletes as rows with __deleted=true, which warehouse sinks can apply. Dropping
deletes silently is the most common CDC correctness bug.
Transactional outbox
Publish domain events reliably by writing them to an outbox table in the same transaction as the business change, and let Debezium route them.
create table outbox (
id uuid primary key,
aggregatetype varchar(255) not null, -- routes to a topic, e.g. "order"
aggregateid varchar(255) not null, -- becomes the message key
type varchar(255) not null, -- event type, e.g. "OrderPlaced"
payload jsonb not null,
created_at timestamptz not null default now()
);
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.topic.replacement": "${routedByValue}.events",
"transforms.outbox.table.expand.json.payload": "true"
Messages are keyed by aggregateid and carry the outbox id as a header; consumers dedupe on
it. Purge old outbox rows on a schedule (or delete in the same transaction right after insert;
the WAL still carries the insert). For design questions beyond Debezium wiring, see $outbox-pattern.
Operating safely
The main risk is an inactive replication slot retaining WAL until the database disk fills.
select slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) as retained
from pg_replication_slots;
- Alert when retained WAL exceeds a threshold (for example 10 GB or 20% of free disk).
- Set
max_slot_wal_keep_sizeso a dead connector invalidates its slot instead of taking down the primary (the connector then needs a re-snapshot, whichwhen_neededcan do). - Low-traffic tables in a busy database: heartbeats plus
heartbeat.action.querywriting to a heartbeat table keep the confirmed LSN moving. - Dropping a slot (
pg_drop_replication_slot) loses the stream position; get explicit confirmation and plan a re-snapshot first.
Kafka Connect REST checks (worker network access needed):
curl -s http://connect:8083/connectors/shop-pg/status | jq
curl -s -X POST "http://connect:8083/connectors/shop-pg/restart?includeTasks=true&onlyFailed=true"
Downstream apply
- Upsert by primary key and apply deletes; order by
source.lsn(Postgres) or binlog position when batching, because one batch can contain several versions of a row. - Expect duplicates after connector restarts (at-least-once). Idempotent upserts make them harmless.
- Schema changes: Postgres pgoutput carries new columns in events after the change; add them downstream deliberately. Dropping a column upstream should fail loudly in the sink.
Verification and report
- Insert, update and delete a test row in a non-production database; confirm three events with
correct
op, key and before/after, and that the sink reflects the delete. - Compare row counts and a checksum of a column between source and sink after the snapshot.
- Watch retained WAL and connector lag for a full business day.
**Connector:** shop-pg (Debezium 3.x, pgoutput, slot debezium_shop, publication dbz_shop)
**Tables:** public.orders (replica identity full), public.customers
**Snapshot:** initial, 41.2M rows in 38 min; counts match source
**Deletes:** rewritten with __deleted=true, applied by sink MERGE
**Safety:** max_slot_wal_keep_size 50GB; alert at 10 GB retained; heartbeat 10 s
**Test:** c/u/d round-trip verified in staging What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "CDC with Debezium"
short_description: "Every insert, update and delete, in order"
default_prompt: "$cdc-debezium Stream changes from our Postgres orders table into Kafka with Debezium" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.