Deep Engineering
Advanced·Published·30 MIN

The transaction outbox: what it guarantees, what it does not, and what it costs

The event becomes a row in the same database, and then it shares the order's fate. The interesting part starts after that: a relay that remembers the id of the last row it sent loses events silently, and the delivery order the pattern promises is not delivered at all — neither by polling the table nor by reading the WAL.

Full technical treatment

TL;DR

The task sounds innocent: record the order and tell everyone else about it. Two systems are enough to make it unsolvable directly — between the two writes there is always a moment where the process can crash.

  • The outbox turns the message into a row of the same database, and then it shares the order's fate: one commit, either both or neither.
  • It is cheap. Measured on PostgreSQL 16.13: the outbox row adds 30 % to the transaction, while the usual way of avoiding it — writing the event in a separate transaction — costs twice as much. The unsafe way is also the slower one here.
  • The mistake that never announces itself is a relay that remembers the id of the last row it sent. It loses events silently: no exception, no log line, and a lag metric reading zero.
  • The pattern does not give you ordering — not once two transactions have overlapped in time. The promise on the pattern's canonical page — Messages are sent to the message broker in the order they were sent by the application — holds only while they do not overlap; let two overlap and what arrives is commit order. Neither polling nor reading the WAL changes that.
  • “Exactly once” does not exist. The process can crash between publishing and marking too, so idempotency belongs to the consumer, not to the sender.

A problem with no direct solution

The handler does two things: it writes the order to the database and tells the broker about it.

PYTHON
def place_order(request):
    order = Order(total=request.total)
    db.insert(order)
    db.commit()
    broker.publish(OrderPlaced(order.id))

Between commit() and publish() the process can die. Not “in theory”: a deploy, the OOM killer, a lost connection to the broker, a timeout. Swap the lines and it does not get better — it gets different.

Both orders are wrong, and in different ways. A lost event is quiet: each system believes everything is fine, and you find out from a user. A phantom event is loud: the consumer reserves stock or sends a letter about an order that does not exist.

There is no third order. As long as the broker takes no part in the database's transaction, there is always a moment between the two writes.

A distributed transaction across two resources would solve this problem — but the price is a coordinator and locks held for the duration of the agreement. And for a broker it is not an option at all: Kafka does not take part in two-phase commit.

The solution: make the event a row of the same database

The solution is for the service that sends the message to first store the message in the database as part of the transaction that updates the business entities. A separate process then sends the messages to the message broker.

Chris Richardson, Pattern: Transactional outbox
SQL
CREATE TABLE outbox (
    id           bigserial PRIMARY KEY,
    payload      jsonb       NOT NULL,
    published_at timestamptz
);
PYTHON
def place_order(request):
    order = Order(total=request.total)
    db.insert(order)
    db.insert(Outbox(payload={"type": "OrderPlaced", "order_id": order.id}))
    db.commit()          # both rows or neither

Notice what is not here. There is nothing specific to the pattern: this is ordinary transactional atomicity. The whole invention is that the message stopped being a message and became a row.

Next you need a second process — the relay — which reads the rows and sends them to the broker. That is where all the real questions live.

payload jsonb is not a schema, it is the absence of one

In the table above payload is a jsonb column, and that is a simplification you pay for later. The relay neither reads nor validates its contents:

The outbox event router SMT supports arbitrary payload formats. The SMT passes on payload column values that it reads from the outbox table without modification.

Debezium — Outbox Event Router

So between producer and consumer there is not one point where an incompatible change would be noticed. It surfaces at the consumer, in production, on messages that can no longer be un-sent.

Debezium's reference shape for the table is therefore wider than three columns:

id            | uuid                   | not null
aggregatetype | character varying(255) | not null
aggregateid   | character varying(255) | not null
type          | character varying(255) | not null
payload       | jsonb                  |

Every column here carries something you would otherwise have to solve in code. aggregatetype decides the topic name, aggregateid becomes the message key (and therefore the partition, and therefore the order), type travels as a header, and id is what the consumer deduplicates by: You can use this ID, for example, to remove duplicate messages.

The difference from the bigserial in our DDL matters, and the next section comes back to it.

On the envelope itself. The moment there is more than one field, the message value stops being a bare payload:

A representation of the outbox change event. The default structure is JSON. By default, the Kafka message value is solely comprised of the payload value. However, if the outbox event is configured to include additional fields, the Kafka message value contains an envelope encapsulating both payload and the additional fields, and each field is represented separately.

Ibid.

What to do about a schema change. The compatibility rules need not be invented — they are written down in the formats built for this. Avro states them as reading rules, and both directions of compatibility follow directly:

if the writer's record contains a field with a name not present in the reader's record, the writer's value for that field is ignored.

Apache Avro 1.11.1 — Schema Resolution

if the reader's record schema has a field that contains a default value, and writer's schema does not have a field with the same name, then the reader should use the default value from its field.

Ibid.

The first rule covers an old reader against a new writer, the second a new reader against an old writer. Neither holds if the new field has no default: then the old message simply will not read.

Protocol buffers name the same three rules outright: Adding new fields is safe, Removing fields is safe and, against them, Changing field numbers for any existing field is not safe. Plus one caveat that has no counterpart in a JSON schema: a removed number must not be reused, which is what reserved is for.

The practical rule for an outbox falls out of this by itself: you cannot change the meaning of an existing field; you can add a field that has a default; you can remove one no consumer requires. Everything else is a new type, not a new version of the old one. And if you want a check rather than a gentlemen's agreement, Debezium names the way:

Using Avro can be beneficial for message format governance and for ensuring that outbox event schemas evolve in a backwards-compatible way.

Ibid.

Replaying events: why it is not "just read the table again"

Sooner or later a topic has to be refilled: a consumer appeared after the events, the topic was deleted, the data was corrupted. Debezium lists the occasions outright:

You might want to perform an ad hoc snapshot after any of the following changes occur in your Debezium environment: The connector configuration is modified to capture a different set of tables. Kafka topics are deleted and must be rebuilt. Data corruption occurs due to a configuration error or some other problem.

Debezium — Ad hoc snapshots

And immediately after, the sentence this section exists for:

When you initiate an ad hoc snapshot of an existing table, the connector appends content to the topic that already exists for the table.

Ibid.

Appends, not replaces. The consumer gets a second copy of everything. This is not a defect of the tool: a topic has no "overwrite" operation, and cannot have one. So the whole weight of replaying falls on deduplication at the consumer — the very thing that at-least-once delivery makes mandatory, and which has a section of its own below:

Make a consumer idempotent by having it record the IDs of processed messages in the database. When processing a message, a consumer can detect and discard duplicates by querying the database.

Chris Richardson — Pattern: Idempotent Consumer

And here the id bigserial in our DDL stops being good enough. Deduplication works on a stable event identifier, and a bigserial hands out a new number on replay — the same INSERT … ON CONFLICT DO NOTHING will let the duplicate through as a new event. That is why Debezium's id is a uuid set on insert rather than by a sequence. Changing this is cheaper before the first replay than after.

A second consequence, and it contradicts the section on table growth below. The advice to delete sent rows — DROP a partition rather than DELETE — saves space at precisely the cost of the ability to replay. A dropped partition is deleted history. There is one question to settle here, and better explicitly: how far back are you prepared to replay events. The partition retention follows from that answer, not the other way round.

As for the consumer side (reset the offset and read the topic again) — the same thing from the other end: duplicates come not only from re-sending but from re-reading, and one and the same table of processed identifiers protects against both.

What it costs

The objection to an outbox is almost always the same one: an extra write on the hot path. The objection is testable.

The outbox row adds 30 % to the transaction — it travels in the same WAL and commits with the same fsync as the order. Meanwhile the popular way to “keep the transaction light” — writing the event separately — costs twice as much, because it has two commits and two fsyncs. The unsafe option is the slower one.

This is the case where intuition points exactly the wrong way: people avoid the outbox for performance reasons and end up with the slower solution.

The relay: two ways, and not a matter of taste

Publish messages by polling the database's outbox table.

Chris Richardson, Pattern: Polling publisher

Tail the database transaction log and publish each message/event inserted into the outbox to the message broker.

Chris Richardson, Pattern: Transaction log tailing

The difference is not reliability: both of them deliver everything. There are five differences, and the usual deciding one is the last — the cost of running it:

polling the tabletailing the log
works onany SQL databaseone specific database, separately for each
latencyno better than the polling intervaldoes not wait for an interval: the log pushes
when idlea query per intervalnothing: the log pushes
table shapeneeds a state column and an UPDATEINSERT only, no state
operationsan ordinary processa replication slot that grows the WAL if it falls behind

Debezium, the main implementation of log reading, states the requirement outright:

All changes in an outbox table are expected to be INSERT operations. That is, an outbox table functions as a queue; updates to records in an outbox table are not allowed.

Debezium, Outbox Event Router

Which means the table for these two ways looks different, and moving from one to the other is not a change of process but a schema migration.

The mistake that costs the most

The most natural implementation of a polling relay remembers how far it got:

SQL
SELECT id, payload FROM outbox WHERE id > :last_id ORDER BY id;

It is wrong, and it breaks not in a rare case but in the ordinary one.

The cause is that two moments in a row's life have come apart. bigserial issues the number at INSERT — from a sequence, outside the transaction. The row becomes visible to others at COMMIT. Between those two moments lies as much time as the transaction takes, and in that time anyone can take a larger number and commit sooner.

The rest is arithmetic: the relay saw row 2, raised its mark to 2, and row 1 appeared afterwards. The condition id > 2 will never find it again.

No exception, no log line. A “relay lag” metric built as max(id) - last_id reads zero: as far as it is concerned, everything has been sent.

The cure is to give up the memory — the state moves into the row itself:

SQL
SELECT id, payload FROM outbox
  WHERE published_at IS NULL
  ORDER BY id
  LIMIT 100
  FOR UPDATE SKIP LOCKED;

The condition “not published” remembers nothing, so it finds the row again no matter how long it sat uncommitted. Log reading does not have this problem by construction: the log is a sequence of commits, and entries appear in it in the order the commits happened.

What the pattern does not give: ordering

Among the benefits on the pattern's canonical page stands this:

Messages are sent to the message broker in the order they were sent by the application.

Chris Richardson, Pattern: Transactional outbox

The promise holds while transactions do not overlap in time: then insert order and commit order coincide and there is nothing to argue about. Let two overlap and it stops being true. The experiment is exactly the one from the previous section: the application inserted A, then B, and they will be delivered B, then A — because B committed first.

Through log reading, the same. test_decoding shows the transaction carrying row id = 2 before the transaction carrying row id = 1, and the one that arrived first has the larger transaction number: it started later and committed earlier. So the order is neither by row number nor by transaction number, but by the moment of commit.

The page about the polling relay admits the same difficulty — Tricky to publish events in order sits among its drawbacks. The measurement sharpens what kind of difficulty it is: not one that care can overcome, but a property of any relay on PostgreSQL.

What to do about it. You will not have a global order — you cannot lean on one, and you must not write the consumer as if it existed. Order can be preserved only within a single key, and only while a single relay reads the table: the two relays with SKIP LOCKED from the section below break that too. That is what the production implementation is built on: Debezium puts the aggregate id into the Kafka message key — This is important for maintaining correct order in Kafka partitions. Events of one order land in one partition and arrive in order; events of different orders are not ordered against each other — and should not be.

What the pattern does not give: “exactly once”

The relay does two things: it publishes and it marks. Between them, as at the very start of this article, the process can crash.

The Message relay might publish a message more than once.

Chris Richardson, Pattern: Transactional outbox

You cannot swap them: then a crash between them would lose the event, which is strictly worse than a duplicate. So the choice fell on the duplicate, and it is final — at the sender's level this problem has no solution at all.

Which means idempotency has to live at the consumer. The usual way is a table of processed ids:

SQL
INSERT INTO processed (event_id) VALUES (:id) ON CONFLICT DO NOTHING;
-- 0 rows inserted -> a duplicate, do not do the work

The key point is that inserting the id and doing the work go in one transaction. Otherwise you have rebuilt the very dual write you started from.

Two relays

One relay is a single point of failure and a ceiling on throughput. Two relays on one table block each other unless you take a specific measure: the second waits for the first to release its rows.

With SKIP LOCKED, any selected rows that cannot be immediately locked are skipped. Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table.

PostgreSQL 16, The Locking Clause

Checked: with SKIP LOCKED the first relay took rows 1–3 and the second 4–6, with an empty intersection. Without it the second relay dies on a lock_timeout = 300ms without taking a single row — it does not speed anything up, it idles.

The caveat about an inconsistent view is not fine print here. It is precisely what means the order is broken once more: the second relay overtakes the first. For a queue that is acceptable exactly because we no longer have an order.

Before adding a second relay it is worth turning the cheaper knob. A batch of one gives 1,627 rows/s; a batch of a thousand, 104,656 — almost all of the difference is commits, and one process with a large batch outruns several processes with a small one. And with a single relay, order within a key is still intact.

The table that grows

Two things break an outbox in production, and both are about size.

The first is the idle poll. The relay spends nearly all of its time asking “is there work” and hearing “no”. If the queue is empty while the published rows sit in the same table, a query with no suitable index scans everything that has piled up: on a 71 MB table that is 24 milliseconds for every idle poll.

A partial index fixes this.

A partial index is an index built over a subset of a table; the subset is defined by a conditional expression (called the predicate of the partial index). The index contains entries only for those table rows that satisfy the predicate.

PostgreSQL 16, Partial Indexes
SQL
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;

24 milliseconds turn into 53 microseconds. And the crucial property: the index does not grow with the table. With an empty queue it occupies 8192 bytes — one page, the minimum — while the table next to it is 71 MB. An ordinary index on published_at cannot do this: it holds an entry for every row.

The second is cleanup. Published rows have to be deleted, and DELETE does not give the space back: half a million rows delete in 0.55 s, and the table is still the same 71 MB. VACUUM will return the pages for reuse (0.10 s, 11 MB afterwards), but it hands the operating system only the tail of the file.

Hence the usual advice for a noticeable flow: partitions by time and a DROP of the partition instead of a DELETE. DROP removes the file whole — and that is the only operation that returns space immediately.

When you do not need an outbox

The pattern solves one task: reconciling a write to the database with a message going out. If there is no such task, it is redundant.

  • If the recipient is the same database, no outbox is needed: write in one transaction.
  • If the message can be lost without consequence (a metric, a log line, a cache warm-up), the pattern does not pay for itself: publish directly.
  • If the consumer re-reads state from the database regularly anyway, the event is only a hint to look sooner, and losing it costs latency rather than a divergence.

And one case where it is mandatory: when the event causes an irreversible action somewhere — a charge, a shipment, a letter — and a divergence will not be noticed at once.

Potentially error prone since the developer might forget to publish the message/event after updating the database.

Chris Richardson, Pattern: Transactional outbox

The drawback is named precisely: you can forget to insert the outbox row too. The difference is that a forgotten row is a mistake in one place in the code, visible in review, whereas a diverging dual write is a property of the architecture, visible nowhere.

Reproducing the numbers

Two scripts, both against a live database, both printing what PostgreSQL returned.

createdb -p 5433 outbox_bench
python3 relay.py     # eight observations, not a single timing
python3 cost.py      # four blocks of measurements

The scripts live in the repository's measurement directory, next to a write-up of what came out. psycopg 3 is required; the database address comes from OUTBOX_DSN. Section 5 of relay.py reads the WAL and needs wal_level = logical — at any other level it says so and skips itself, while the other seven run anywhere.

The published run: PostgreSQL 16.13, synchronous_commit = on, fsync = on, psycopg 3.3.4, Python 3.11.15, August 2026. Everything measured here bottoms out in fsync, so on another disk the absolute values will differ — what carries over are the ratios within a block and the orders of magnitude.

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Common misconceptions

Claim

An outbox is there so you do not lose a message when the broker goes down

Actually

Not when the broker goes down, but when your process crashes between the two writes. An unavailable broker is handled by retries, with no outbox at all. The task the pattern solves is named precisely on its canonical page: How to atomically update the database and send messages to a message broker? Atomically — that is the word everything here is built around.

Claim

An outbox row is an extra write that slows the hot path down

Actually

Measured on PostgreSQL 16.13: an order with no event takes 301 µs, the same order with an outbox row in one transaction takes 392 — plus 30 %. The row travels in the same WAL and commits with the same fsync. Meanwhile the usual way around that “extra write” — putting the event in a separate transaction — costs 632 µs, twice as much, because it buys a second fsync. The unsafe option is also the slower one.

Claim

A relay can simply be written as WHERE id > :last_id

Actually

This mistake gives you no exception, no log line and no lag in the metric — which is what makes it expensive. bigserial issues the number at INSERT while the row becomes visible at COMMIT; a transaction that took its number earlier can commit later and appear behind what has already been read. Reproduced: the mark moved to 2, and the row numbered 1 will never be sent. No exception, no log line, and a max(id) − last_id metric reads zero.

Claim

An outbox preserves the order of events

Actually

The canonical page promises Messages are sent to the message broker in the order they were sent by the application, and that holds while transactions do not overlap in time. Let two overlap and it is over: the application inserted A, then B, and delivery is B, then A, because B committed first. Log reading gives the same, and the one that arrived first has the LARGER transaction number — it started later and committed earlier. The page about the polling relay admits the same difficulty — Tricky to publish events in order — and the measurement sharpens it into a property of any relay on PostgreSQL rather than a matter of care. Order survives only within a single key, and only with a single relay.

Claim

Tailing the transaction log is more reliable than polling the table

Actually

Everything arrives on both paths — the difference is not reliability. It is latency (polling cannot answer sooner than its own interval; the log does not wait for one), portability (any SQL database against a solution per database), the shape of the table (Debezium requires that All changes in an outbox table are expected to be INSERT operations, so a state column has no place there) and operations: a replication slot that falls behind prevents WAL from being removed and can fill the disk.

Claim

With an outbox a message is delivered exactly once

Actually

The process can crash between publishing to the broker and marking the row, and the pattern admits it: The Message relay might publish a message more than once. Swapping the two operations is not an option — then a crash would lose the event, which is strictly worse. The choice in favour of a duplicate is final, and idempotency belongs to the consumer: inserting the event id and doing the work go in one transaction.

Claim

Published rows can simply be removed with a DELETE

Actually

They do have to be removed, but that does not give the space back. Measured: half a million published rows delete in 0.55 s, and the table is still the same 71 MB — the rows are merely marked dead. VACUUM returns the pages for reuse (0.10 s, 11 MB afterwards) but hands the operating system only the tail of the file. For a noticeable flow, partitions by time and a DROP of the partition are what help.

Check yourself

Question 1 of 5

A handler writes the order, commits, then publishes the event to the broker. The process died between the commit and the publish. What happened?

Sources & further reading

9 SOURCES

  1. Chris Richardson — Pattern: Transactional outboxSource. The canonical description of the pattern. The problem: «How to atomically update the database and send messages to a message broker?». The solution: «The solution is for the service that sends the message to first store the message in the database as part of the transaction that updates the business entities. A separate process then sends the messages to the message broker». Among the benefits stands a claim about ordering: «Messages are sent to the message broker in the order they were sent by the application» — the one this article puts to the test. Among the drawbacks stands exactly one line — «Potentially error prone since the developer might forget to publish the message/event after updating the database», quoted at the end of this article. The possibility of a duplicate is admitted in a separate Issues section: «The Message relay might publish a message more than once».https://microservices.io/patterns/data/transactional-outbox.html
  2. Chris Richardson — Pattern: Polling publisherSource. Delivery by polling the table: «Publish messages by polling the database's outbox table». The single benefit listed: «Works with any SQL database». And among the drawbacks stands exactly what contradicts the ordering promise on the pattern's own page: «Tricky to publish events in order».https://microservices.io/patterns/data/polling-publisher.html
  3. Chris Richardson — Pattern: Transaction log tailingSource. The second way to deliver: «Tail the database transaction log and publish each message/event inserted into the outbox to the message broker». Benefits: «No 2PC», «Guaranteed to be accurate». Two of the three drawbacks matter here: «Requires database specific solutions» and «Tricky to avoid duplicate publishing»; the third is «Relatively obscure although becoming increasing common».https://microservices.io/patterns/data/transaction-log-tailing.html
  4. PostgreSQL 16 — SELECT, The Locking ClauseOfficial documentation. The permission that lets two relays work on one table: «With SKIP LOCKED, any selected rows that cannot be immediately locked are skipped. Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table». The caveat about an inconsistent view is not fine print here: it is the very reason this technique fits a queue and nothing else.https://www.postgresql.org/docs/16/sql-select.html
  5. PostgreSQL 16 — Partial IndexesOfficial documentation. The definition: «A partial index is an index built over a subset of a table; the subset is defined by a conditional expression (called the predicate of the partial index). The index contains entries only for those table rows that satisfy the predicate». And the reason it suits a queue: «One major reason for using a partial index is to avoid indexing common values».https://www.postgresql.org/docs/16/indexes-partial.html
  6. Debezium — Outbox Event RouterOfficial documentation. The production implementation of log reading for an outbox. The requirement on the table's shape, which is what separates this path from polling: «All changes in an outbox table are expected to be INSERT operations. That is, an outbox table functions as a queue; updates to records in an outbox table are not allowed. The SMT automatically filters out DELETE operations on an outbox table». And why the table carries a separate aggregate-id column: «The SMT uses this value as the key in the emitted outbox message. This is important for maintaining correct order in Kafka partitions».https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
  7. Apache Avro 1.11.1 — Schema ResolutionOfficial documentation. The compatibility rules stated not as mode names but as reading rules. Forward: "if the writer's record contains a field with a name not present in the reader's record, the writer's value for that field is ignored." Backward: "if the reader's record schema has a field that contains a default value, and writer's schema does not have a field with the same name, then the reader should use the default value from its field." Both stop working if the new field has no default.https://avro.apache.org/docs/1.11.1/specification/
  8. Protocol Buffers — Language Guide (proto 3), Updating A Message TypeOfficial documentation. The same three rules, named outright: "Adding new fields is safe", "Removing fields is safe" and "Changing field numbers for any existing field is not safe." Plus a requirement with no counterpart in a JSON schema: a removed field number must not be reused — that is what `reserved` is for.https://protobuf.dev/programming-guides/proto3/
  9. Chris Richardson — Pattern: Idempotent ConsumerSource. The deduplication method all protection against re-sending and re-reading comes down to: "Make a consumer idempotent by having it record the IDs of processed messages in the database." Hence the requirement on the event identifier: it must be stable across runs, or a replayed duplicate passes as a new event.https://microservices.io/patterns/communication-style/idempotent-consumer.html