The Outbox Pattern (Reliable Events)

The outbox pattern fixes the dual-write problem - where you save to the database and publish an event, but a crash between them loses the event. You write both in one transaction, then a separate poller publishes. Here's how.

Data Engineerpythonpostgresoutbox

The problem it solves (dual write)

A common flow: save an order to the database, then publish an order_created event to a queue. But that's two systems, and there's no transaction across them:

# BROKEN: if the process dies between these, the order exists but no event is sent
db.insert_order(order)
sqs.publish("order_created", order)   # crash here -> event lost forever

Downstream consumers (notifications, analytics) silently miss the event. Retrying naively risks the opposite - publishing twice, or publishing for an order that rolled back.

The fix: write the event to an outbox table, in the same transaction

Instead of publishing inline, insert an outbox row in the same database transaction as the business write. Either both commit or neither does - atomic:

def create_order(customer, amount):
    with conn:                      # one transaction: commit or rollback together
        cur = conn.cursor()
        cur.execute("INSERT INTO orders (customer, amount) VALUES (%s, %s) RETURNING id",
                    (customer, amount))
        order_id = cur.fetchone()[0]
        cur.execute(
            "INSERT INTO outbox (event_type, payload) VALUES ('order_created', %s)",
            (json.dumps({"order_id": order_id, "customer": customer, "amount": amount}),))
    return order_id

No queue call here - just two inserts that commit together.

The poller publishes the outbox

A separate process reads unpublished outbox rows and sends them to the queue, marking each as sent only after the publish succeeds:

def publish_outbox():
    rows = db.query("SELECT id, event_type, payload FROM outbox WHERE published_at IS NULL")
    for r in rows:
        sqs.publish(r["event_type"], r["payload"])           # publish
        db.execute("UPDATE outbox SET published_at = now() WHERE id = %s", (r["id"],))

Why this gives at-least-once delivery

In the real world

Tools like Debezium read the outbox table's change log directly (change-data-capture) so you don't even run a poller. The guarantee is the same: write once, atomically; deliver at least once; dedupe on the consumer.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

What is the outbox pattern?

A way to reliably publish events: write your business data and an 'outbox' event row in one database transaction, then a separate poller (or CDC tool) reads the outbox and publishes the events. It removes the dual-write race where a crash loses the event.

Does the outbox pattern give exactly-once delivery?

No - it gives at-least-once. If the poller crashes after publishing but before marking the row sent, it republishes next run. Make consumers idempotent (dedupe on the event id) so duplicates are harmless.

Why not just publish the event right after saving to the database?

Because the save and the publish are two systems with no shared transaction - a crash between them loses the event (or a naive retry double-sends). The outbox makes the event part of the same atomic database write.

Keep learning

Make an ETL Pipeline IdempotentData projectSpeed Up a Slow SQL Report QueryData projectMake a CSV Importer Resilient to Bad DataData projectData roadmapStep by step to hiredData interview questionsSTAR answersAll Data projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →