Skip to content

djoutbox

Transactional outbox pattern for Django + PostgreSQL + RabbitMQ.

What is the transactional outbox pattern?

When building applications, you often need to perform two operations together: save data to a database and trigger side effects (send an email, call an external API, publish an event). Both operations must succeed or fail together to maintain consistency. If an error occurs between the database change and the side effect, your application is left in an inconsistent state and requires manual intervention.

The outbox solution:

Instead of calling side effects directly, write event messages to an outbox table in the same database transaction as your business data. A separate reliable process (the message relay) reads from this table and publishes messages to RabbitMQ.

Benefits:

  • Atomic operations — Database changes and event publishing succeed or fail together within a single Django transaction.
  • Guaranteed delivery — At-least-once delivery semantics ensure messages are eventually published.
  • No distributed transactions — Everything happens within a single database transaction.
  • Reliable retries — If publishing to RabbitMQ fails, the message relay keeps trying.
  • Django-native — Uses Django migrations, ORM models for the outbox table, and the Django admin for visibility.

How it works

flowchart LR
    MA{Your Django app} -->|"publish()"| P[(djoutbox_pending)]
    P ~~~ MR{Message relay}
    MR -->|SELECT FOR UPDATE| P
    MR ~~~ P
    MR --->|publish| ME["Exchange (topic)"]
    P --x|DELETE + INSERT INTO sent| S[(djoutbox_sent)]

    subgraph Database
    P
    S
    end

    subgraph RabbitMQ
    ME -->|binding| Q1[Queue 1]
    end

    Q1 ~~~ W{Worker} --->|"consume()"| Q1
    W ~~~ Q1

Quick start

# 1. Install
pip install djoutbox

# 2. Add to INSTALLED_APPS
INSTALLED_APPS = [
    ...,
    "djoutbox",
]

# 3. Configure
DJOUTBOX = {
    "rmq_url": "amqp://guest:guest@localhost/",
    "exchange_name": "outbox",
}

# 4. Migrate
./manage.py migrate

# 5. Publish
from djoutbox import publish

def create_user(request):
    with transaction.atomic():
        user = User.objects.create(username="johndoe")
        publish("user.created", {"id": user.id, "username": user.username})

# 6. Run the relay (separate process)
import asyncio
from django.conf import settings
from djoutbox import Relay
from djoutbox.conf import build_dsn

asyncio.run(Relay(db_dsn=build_dsn(), **settings.DJOUTBOX).run())

# 7. Write a worker (separate process)
import asyncio
from djoutbox import Worker, consume

@consume("user.created")
async def handle_user_created(user: dict):
    print(f"User created: {user}")

worker = Worker(consumers=[handle_user_created], rmq_url="amqp://guest:guest@localhost/")
asyncio.run(worker.run())

Key features

Feature Description
Django integration Migrations, ORM models, admin interface, settings-based configuration

What djoutbox is not

  • Not a task queue — djoutbox implements the outbox pattern for event publishing. It does not replace Celery or similar task queues. Workers are event consumers, not general-purpose task runners.
  • Not a replacement for your existing messaging — Use it alongside your existing infrastructure. It only handles the outbox → RabbitMQ path.
  • Not a framework — It's a library. You write the worker scripts and consumer functions.

Relationship to outbox

djoutbox is a port of outbox (an async Python library for PostgreSQL + RabbitMQ) into a Django application. Key differences:

Feature outbox djoutbox
Storage Single outbox_table with sent_at Two tables: djoutbox_pending + djoutbox_sent (partitioned)
Table creation Runtime ensure_outbox_table() Django migrations
Configuration Constructor arguments settings.DJOUTBOX dict
Relay entrypoint User script Standalone async script
Publisher Async (asyncpg) Sync (Django ORM)
Async publish Native async Via sync_to_async