PostgreSQL Transactional Outbox with Kafka
establishedImplementation path for outbox_pattern on a PostgreSQL primary publishing to Kafka: a minimum-viable polling publisher first, then a change-data-capture (Debezium + Kafka Connect) production path that removes polling latency and read load.
Minimum Viable Implementation
- 1.Add an outbox table in the same PostgreSQL database as the business tables: id (uuid pk), aggregate_type, aggregate_id, event_type, payload (jsonb), created_at, published_at (nullable).
- 2.In the same transaction as the business write, INSERT the corresponding outbox row. This is the atomicity the pattern depends on: both writes commit or roll back together.
- 3.Run a polling publisher: a scheduled job that selects unpublished rows (published_at IS NULL, ordered by created_at), publishes each to Kafka, then sets published_at.
- 4.Periodically delete or archive published rows to bound table growth.
Estimated effort: 1-2 days for a single-table polling implementation.
Production Implementation
- 1.Replace the polling publisher with Debezium's PostgreSQL connector reading the outbox table via logical replication, removing polling latency and the repeated read load of the poller.
- 2.Set REPLICA IDENTITY DEFAULT on the outbox table (FULL is unnecessary: outbox rows are inserted then deleted, never meaningfully updated, so the full old-row image FULL would emit on every WAL change is wasted volume).
- 3.Create a dedicated logical replication slot for the connector; do not share it with other consumers.
- 4.Configure Debezium's outbox event router (the io.debezium.transforms.outbox.EventRouter single message transform) to reshape the raw change-capture event into a clean domain event per aggregate_type, dropping outbox-only columns from the published payload.
- 5.Run Kafka Connect in distributed mode, not standalone, so connector state survives a worker restart or rebalance; keep the connector configuration under version control.
- 6.Delete published outbox rows on a schedule (or drive deletion from the CDC delete event itself) so the table does not grow unbounded; a large outbox table slows the connector's snapshot phase on restart.
Estimated effort: 1-2 weeks including Kafka Connect cluster setup, if one does not already exist; less if a shared Connect cluster is already running.
Schema / Infrastructure Changes
- ·New outbox table with a jsonb payload column and a monotonic ordering column.
- ·PostgreSQL wal_level must be set to logical (default is replica); this requires a server restart, not just a config reload, so it needs its own maintenance window.
- ·A dedicated logical replication slot for the Debezium connector.
- ·A Kafka Connect cluster (or a shared multi-tenant one) with the Debezium PostgreSQL connector plugin installed.
Observability Checklist
- ·Replication slot lag: the distance between pg_current_wal_lsn() and the slot's confirmed_flush_lsn. This is the single most important metric here; alert on it well before disk fills.
- ·Debezium connector task status (RUNNING vs FAILED) via the Kafka Connect REST API.
- ·Outbox table row count and oldest unpublished row age, as a backstop signal independent of CDC lag monitoring.
- ·Consumer lag on the outbox topic downstream of Kafka Connect.
Deployment Plan
- ·Create the outbox table and enable logical replication in a maintenance window before deploying any application code that writes to it.
- ·Deploy the Debezium connector and confirm it reaches RUNNING with a fresh, non-lagging slot before enabling application writes to the outbox table.
- ·Roll out the application-side outbox write behind a feature flag or canary; a partial rollout does not reopen the dual-write gap, since each instance's writes are still atomic per-transaction, but confirms behavior before it applies globally.
Rollback Plan
- ·Disable the Debezium connector and drop its replication slot immediately if rolling back; an orphaned slot retaining WAL is the primary rollback risk.
- ·Application code that writes to the outbox table can stay in place after rollback: an unpublished outbox row is inert, not a correctness problem, until a publisher resumes reading it.
- ·If reverting from CDC back to polling, the polling publisher resumes from published_at IS NULL, not from a Kafka offset; this is a different resumption model and must be re-verified, not assumed equivalent.
Failure Drills
Kill the Kafka Connect worker process while application writes continue.
Outbox table writes keep succeeding: they are ordinary PostgreSQL transactions with no synchronous dependency on Kafka Connect. Once the connector restarts, it resumes from its last committed position in the replication slot; the backlog publishes in order, nothing is lost.
queue backlog accumulation →Leave the replication slot unconsumed (connector stopped) under sustained write load.
WAL accumulates on the primary because a logical slot's confirmed_flush_lsn does not advance while nothing reads it. Disk usage climbs and, left unaddressed, threatens the primary's availability, not just event delivery: this is a database incident, not just a messaging delay.
replication lag cascade →Common Footguns
- ·Setting REPLICA IDENTITY FULL on the outbox table out of habit: it is unnecessary here and inflates WAL volume for no benefit, since outbox rows are never meaningfully updated.
- ·Running both a polling publisher and Debezium against the same outbox table: pick one mechanism, running both double-publishes every event.
- ·Not bounding outbox table growth: forgetting to delete published rows turns it into an ever-growing table that slows the connector's snapshot on restart.
- ·Assuming outbox alone gives exactly-once delivery: CDC-based publishing is at-least-once (Kafka Connect can redeliver on rebalance), so consumers still need their own idempotency handling, see inbox_pattern.
Related Entities
Patterns
Technologies
Scenarios
Sources & Claims
PostgreSQL's wal_level must be set to logical for a logical-replication-based connector such as Debezium's to consume row changes; this is a server restart, not a reloadable setting.
pendingofficial documentation · PostgreSQL logical replication configuration documentation
An unconsumed logical replication slot prevents WAL from being recycled, so disk usage grows on the primary until the slot is dropped or resumes being read.
pendingofficial documentation · PostgreSQL replication slots documentation
Debezium's outbox event router (io.debezium.transforms.outbox.EventRouter) reshapes a raw change-capture event on an outbox table into a clean domain event, dropping outbox-specific columns from the published payload.
pendingvendor engineering post · Debezium outbox event router single message transform documentation
CDC-based outbox publishing provides at-least-once delivery, not exactly-once; a rebalance or restart can redeliver an already-published event, so consumers must be idempotent.
pendingddia or accepted reference · Standard distributed delivery-semantics reasoning; effectively-once framing as used elsewhere in this knowledge base (see inbox_pattern, outbox_pattern)