Transactional Outbox Pattern for Python — PostgreSQL + NATS JetStream
In event-driven architectures, a service typically needs to do two things atomically:
- Persist a domain state change to PostgreSQL (e.g. insert a
Photorow) - Publish a corresponding event to NATS JetStream (e.g.
photo.created)
These two operations touch two different systems with no shared distributed transaction. This creates silent failure scenarios — the dual-write problem:
- DB commit succeeds → service crashes → event lost (silent inconsistency)
- Event published → DB rollback → phantom event (consumers react to nothing)
- NATS temporarily down → event lost or duplicated without a retry mechanism
natsbox solves this with the Transactional Outbox Pattern:
Write events to a dedicated
outbox_eventstable in the same SQL transaction as your domain data. A separate relay process reads this table and publishes events reliably to NATS JetStream.
- Atomic writes — event and domain data commit together (ACID guaranteed by Postgres)
- At-least-once delivery — events are never lost, even across relay crashes
- Idempotent publishes —
Nats-Msg-Id = event_idenables JetStream dedup on retry - Exponential backoff — failed publishes are retried with backoff via
scheduled_at - Dead-letter queue — events that fail
max_retriestimes are flagged (not silently dropped) - Inbox pattern — optional consumer-side deduplication via
INSERT ... ON CONFLICT - Delayed publish — schedule events in the future with
scheduled_at - Horizontal scaling — multiple relay instances are safe via
SELECT FOR UPDATE SKIP LOCKED - Prometheus metrics — pending count, publish latency, failure rate
- Framework-agnostic core — works with any SQLAlchemy async session
pip install nats-outbox[cli,all]from sqlalchemy.ext.asyncio import AsyncSession
from nats_outbox.core.outbox import outbox_transaction
async def create_photo(session: AsyncSession, url: str, user_id: int):
async with outbox_transaction(session) as tx:
photo = Photo(url=url, user_id=user_id)
tx.add(photo)
await session.flush() # get photo.id before commit
tx.publish_event(
subject="photo.created",
payload={"photo_id": str(photo.id), "user_id": user_id},
aggregate_id=str(photo.id),
aggregate_type="Photo",
)
# Both photo row AND outbox event are committed atomicallyStart the relay:
OUTBOX_DATABASE_URL=postgresql+asyncpg://... \
OUTBOX_NATS_URL=nats://localhost:4222 \
nats-outbox relay start ┌─────────────────────────────────────────┐
│ Your Application │
│ │
HTTP Request ────►│ async with outbox_transaction(db) as tx:│
│ tx.add(Photo(...)) │
│ tx.publish_event("photo.created") │
│ │ │
└───────────────────────────┼──────────────┘
│ SQL COMMIT (atomic)
▼
┌─────────────────────────────────────────┐
│ PostgreSQL │
│ │
│ photos outbox_events │
│ ┌──────────┐ ┌─────────────────────┐ │
│ │ id url │ │ status subject ... │ │
│ │ 1 ... │ │ pending photo.created│ │
│ └──────────┘ └─────────────────────┘ │
└───────────────────────────┬──────────────┘
│ SELECT FOR UPDATE SKIP LOCKED
▼
┌─────────────────────────────────────────┐
│ Relay (separate process) │
│ │
│ PollingRelay.start() │
│ ├── poll every 1s │
│ ├── publish to JetStream │
│ └── mark status=published │
└───────────────────────────┬──────────────┘
│ publish (Nats-Msg-Id=event_id)
▼
┌─────────────────────────────────────────┐
│ NATS JetStream │
│ │
│ Stream: PHOTOS │
│ Subject: photo.created │
│ Dedup window: 2 min (Nats-Msg-Id) │
└─────────────────────────────────────────┘
The outbox_events table is the heart of the pattern:
| Column | Type | Purpose |
|---|---|---|
id |
BIGSERIAL | Sequential PK — no B-tree fragmentation |
event_id |
UUID | Business ID — used as Nats-Msg-Id header (stable across retries) |
subject |
TEXT | NATS subject |
headers |
JSONB | NATS headers (always includes Nats-Msg-Id) |
payload |
JSONB | Event body |
status |
TEXT | pending → published or failed |
retry_count |
SMALLINT | Failed publish attempts |
last_error |
TEXT | Last exception for debugging |
aggregate_id |
TEXT | Source entity ID (for ordering & observability) |
aggregate_type |
TEXT | Source entity class name |
created_at |
TIMESTAMPTZ | When event was written to outbox |
updated_at |
TIMESTAMPTZ | Last relay modification |
scheduled_at |
TIMESTAMPTZ | Earliest publish time (backoff pushes this forward) |
published_at |
TIMESTAMPTZ | When JetStream ACK was received |
nats-outbox relay start --strategy polling| Latency | Up to OUTBOX_POLLING_INTERVAL (default 1s) |
| DB load | Constant light read load (mitigated by partial index) |
| Ordering | FIFO within single relay; weakened under concurrent instances |
| Complexity | Low — no Postgres superuser, no replication slot |
nats-outbox relay start --strategy wal # NotImplementedError — coming soon| Latency | Sub-millisecond (reacts to WAL INSERT in real time) |
| DB load | Zero read polling |
| Ordering | Strict by LSN (total order) |
| Complexity | Requires wal_level=logical, REPLICATION privilege, replication slot |
All configuration is via environment variables (prefix: OUTBOX_):
OUTBOX_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/myapp
OUTBOX_NATS_URL=nats://localhost:4222
OUTBOX_POLLING_INTERVAL=1.0 # seconds between idle polls
OUTBOX_BATCH_SIZE=100 # events per tick
OUTBOX_MAX_RETRIES=5 # before dead-lettering
OUTBOX_RETENTION_DAYS=7 # cleanup window for published events
OUTBOX_JETSTREAM_DEDUP_WINDOW=120 # match your stream's DuplicateWindow
OUTBOX_METRICS_PORT=9090 # 0 = disabledSee the examples/ directory for a complete working FastAPI integration:
Tests use real Docker containers via testcontainers:
make testV2 — WAL Tailing (Logical Replication)
We are planning to implement a sub-millisecond latency relay using Postgres logical replication (pgoutput) via psycopg3. This will replace the polling mechanism for high-throughput systems.
MIT