Different guarantees
Kafka and PostgreSQL are often mentioned in the same architecture diagram, but they do not solve the same consistency problem. PostgreSQL protects changes inside a relational database transaction. Kafka provides an ordered log for events and consumers.
The hard part is the boundary between them. A service changes rows and publishes an event. If those operations are treated as one informal action, failures create ambiguity: the row changed but the event did not publish, or the event published but the row change did not commit.
Good design starts by admitting that this boundary exists.
The PostgreSQL transaction
A database transaction is strong inside the database. It can group row changes, enforce constraints, roll back partial work, and make later readers see a coherent state.
That strength depends on staying inside the database boundary. Calling an external broker, HTTP API, or file system from the middle of a transaction does not make the external system part of the same atomic commit.
When application code forgets that, it often creates hidden two-phase behavior without the machinery that would make two-phase commit reliable.
The Kafka log
Kafka gives durable ordered records inside a topic partition. Producers write records. Consumers read and track offsets. Delivery semantics depend on producer configuration, idempotence, transactions, consumer commits, and how the application handles retries.
Kafka is excellent for distributing facts after they happen. It is not automatically the source of truth for relational invariants such as foreign keys, uniqueness, or balance checks. Those still belong in the database when the domain is relational.
The log shines when consumers need a replayable history. The database shines when the system must enforce current truth.
The outbox pattern
The transactional outbox pattern is a practical bridge. The application writes both the domain change and an outbox row in the same PostgreSQL transaction. A separate publisher reads committed outbox rows and sends them to Kafka.
This does not make Kafka and PostgreSQL one atomic system. It changes the failure mode. If publishing fails, the outbox row remains and can be retried. The application no longer has to guess whether an event was supposed to exist for a committed change.
Consumers still need idempotency because retries can publish duplicates. That is a normal distributed-systems cost, and it is better than silent event loss.
Human error is part of consistency
Many consistency bugs are not caused by Kafka or PostgreSQL being weak. They are caused by unclear ownership. One team thinks the database is authoritative. Another team treats events as commands. A repair script updates rows but forgets events. A consumer assumes event order across partitions.
Technical guarantees only help when the operational story matches them. Document which state is authoritative, which events are derived, how replays work, and how manual repairs should publish or suppress follow-up events.
The safest architecture is not the one with the most infrastructure. It is the one where each boundary has an explicit failure story.
The outbox shape
The most practical way to keep PostgreSQL and Kafka honest is usually not a distributed transaction. It is an outbox table written in the same database transaction as the business change.
BEGIN;
UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = 42;
INSERT INTO outbox_events (topic, event_key, payload, created_at)
VALUES (
'orders.paid',
'42',
jsonb_build_object('order_id', 42, 'status', 'paid'),
now()
);
COMMIT;
A separate dispatcher reads unpublished outbox rows, publishes them to Kafka, and marks them delivered. If the app crashes after the commit but before Kafka publish, the event is still in PostgreSQL. If Kafka publish succeeds but marking the row delivered fails, the dispatcher may publish again, so consumers must be idempotent.
That is the real boundary: PostgreSQL is the source of truth for the state transition, Kafka is the distribution channel, and the outbox is the retryable handoff between them.
Consumer obligations
Once the producer uses an outbox, consumers inherit their side of the contract. They should assume messages can be delivered more than once and sometimes later than expected. That means storing processed event IDs, making writes idempotent, and treating event order as a scoped property rather than a global promise.
The event payload should carry enough information for the consumer to decide safely. A thin event that only says something changed may force the consumer to perform a read at the wrong time. An oversized event may duplicate the whole database row and turn schema evolution into a messaging problem. The right size depends on what the downstream system must decide without guessing.
Schema evolution should be planned at the same time. Additive fields, versioned event names, and tolerant consumers make the pipeline easier to change. A brittle event schema can turn a consistency pattern into a deployment coordination problem.
Operational dashboards should expose outbox lag, publish failures, and consumer retry rates. Without those numbers, consistency problems only appear after users notice stale downstream state.
Sources
- Apache Kafka documentation, message delivery semantics. https://kafka.apache.org/documentation/#semantics
- PostgreSQL documentation, transactions. https://www.postgresql.org/docs/current/tutorial-transactions.html
- Microservices.io, transactional outbox pattern. https://microservices.io/patterns/data/transactional-outbox.html