How to choose an event store backend¶
eventsource ships three event store adapters, structurally conforming to the
FullEventStore protocol in eventsource.ports.store (no shared ABC —
conformance is duck-typed): InMemoryEventStore, SQLiteEventStore, and
PostgreSQLEventStore. They implement the same five store ports (append,
read_stream, read_category, read_all, event_exists, plus
current_position / get_stream_version), so the choice is an infrastructure
decision — your aggregates, projections, and repositories do not change when
you switch.
Use this guide to pick a backend for a specific situation and wire it up:
- Tests and examples —
InMemoryEventStore()takes no dependencies, needs no extra, and requires no setup or teardown. - Local development and single-node deployments —
SQLiteEventStoreneeds thesqliteextra (aiosqlite), self-initializes on first use (noinitialize()call, no context manager), and is only importable fromeventsource.adapters.sqlitewhen aiosqlite is installed. - Production —
PostgreSQLEventStoreneeds thepostgresqlextra (asyncpg), takes a SQLAlchemyAsyncEngineyou own, and is the only backend with the transactional outbox (outbox_enabled=True).
Each backend section below covers install, construction, lifecycle, and the caveats that tend to bite. If you only need to know which one to reach for, read Decide which backend fits and stop there.
Before you start¶
This guide assumes you already have a working eventsource install and some
DomainEvent subclasses to store. Specifically:
- Python 3.13+ with
eventsource-pyinstalled. The base install pulls onlypydanticandsqlalchemy; every backend beyondInMemoryEventStorerequires an optional extra (sqliteorpostgresql), installed separately. - An async runtime. The store ports are async throughout (
append,read_stream,read_all), so all examples here run insideasyncio— underasyncio.run(...), an async web framework, orpytest-asyncio. If you need to call a store from synchronous code, wrap it withSyncEventStoreAdapterinstead of changing backends. - Your event classes importable at startup.
DomainEventsubclasses self-register into anEventRegistrywhen their module is imported (via@register_event). Every backend deserializes stored rows through a registry, so a module that is never imported means events that never come back out. - For
PostgreSQLEventStore: a reachable PostgreSQL server and its schema already provisioned, unless you passcreate_schema=True. Apply the SQL undersrc/eventsource/adapters/sql/schemas/schemas/(or your own migration tool) before first use, or let the adapter create it for you. For local work, the repo'sdocker-compose.test.ymlbrings up a suitable instance.
You do not need to decide permanently. Because all three backends satisfy the same ports, swapping one for another is a change at your composition root, not in your domain code.
Decide which backend fits¶
Pick by deployment shape, then confirm the constraints below match what you need:
- Choose
InMemoryEventStorewhen the store's lifetime is the process's lifetime: unit tests, examples, and prototypes. No extra to install, nothing to provision, nothing to tear down. - Choose
SQLiteEventStorewhen you want durability on one machine with no server to run: local development, CLI tools, embedded or single-node deployments. It self-initializes its schema on first use. - Choose
PostgreSQLEventStorewhen more than one process writes, or when you need the transactional outbox. It is the only backend that writes outbox rows in the same transaction as the events.
Two constraints decide most cases:
- Do multiple processes write to the same streams? If yes, PostgreSQL. The in-memory store is per-instance, and SQLite serializes all writers behind a single connection — safe, but not a scaling story.
- Do you need reliable publishing via the outbox? If yes, PostgreSQL with
outbox_enabled=True. OnlyPostgreSQLEventStorewrites outbox rows on append, in the same transaction as the event insert; neitherInMemoryEventStorenorSQLiteEventStoresupports an outbox.
Everything else — optimistic concurrency via ExpectedVersion, read_stream,
read_all, and stream identity via StreamId — behaves the same across all
three.
Comparison at a glance¶
Use this table to confirm a choice you have already narrowed down.
InMemoryEventStore |
SQLiteEventStore |
PostgreSQLEventStore |
|
|---|---|---|---|
| Persistence | None — state lives in the instance and dies with it | Durable file, or :memory: (per connection, non-durable) |
Durable, managed by the PostgreSQL server |
| Install extra | None; part of the base install | pip install eventsource-py[sqlite] (aiosqlite) |
pip install eventsource-py[postgresql] (asyncpg) |
| Import | eventsource.adapters.memory (also top-level eventsource) |
eventsource.adapters.sqlite, guarded by AIOSQLITE_AVAILABLE — importable but raises ImportError on construction when aiosqlite is missing |
eventsource.adapters.postgresql, guarded by ASYNCPG_AVAILABLE |
| Construction | InMemoryEventStore(store_id="memory", event_registry=None) |
SQLiteEventStore(database, event_registry=None, store_id=None, wal_mode=True, busy_timeout=5000) |
PostgreSQLEventStore(engine, event_registry=None, store_id=None, create_schema=False, outbox_enabled=False) |
| Lifecycle | None — construct and use | None — construct and use; the connection and schema are created lazily on first call | You own the AsyncEngine; dispose it on shutdown. Pass create_schema=True to have the adapter provision its own schema, or provision it yourself via the SQL under migrations/schemas/ |
| Schema | N/A | Created lazily on first use (idempotent) | Provisioned externally via migrations, unless create_schema=True |
| Concurrency | Single process; appends serialized behind a threading.Lock |
Single writer per database file/connection | Multi-process and multi-node, with row-level locking in the server |
| Outbox support | No | No | Yes — outbox_enabled=True writes outbox rows in the append transaction |
Use InMemoryEventStore for tests¶
InMemoryEventStore keeps every event on the instance, structurally
conforming to FullEventStore. It is the right choice when the store should
not outlive the test, example, or prototype that created it.
from uuid import uuid4
from eventsource import ExpectedVersion, InMemoryEventStore, StreamId
store = InMemoryEventStore()
stream = StreamId(aggregate_id=uuid4(), category="Order")
result = await store.append(
stream,
[OrderCreated(...)],
expected=ExpectedVersion.no_stream(),
)
assert result.new_version == 1
Install (no extra required)¶
Nothing to install beyond the base package:
InMemoryEventStore pulls in no database driver — only the standard library
and the core eventsource modules — so it is available on every install.
Import it from either the package root or the adapter module:
from eventsource import InMemoryEventStore
# or
from eventsource.adapters.memory import InMemoryEventStore
Both names are exported unconditionally. Unlike SQLiteEventStore, which is
guarded behind AIOSQLITE_AVAILABLE, there is no *_AVAILABLE flag to check
here.
Construct: InMemoryEventStore(store_id="memory", *, event_registry=None)¶
def __init__(
self,
store_id: str = "memory",
*,
event_registry: EventRegistry | None = None,
) -> None: ...
The common case is a bare call:
The store starts empty and is immediately usable. store_id names the
store for Position comparisons (positions from different store_ids raise
PositionForeignError if you try to order them); pass event_registry if you
are not using the module-level default registry.
There is no tracer or enable_tracing constructor argument on this adapter
— check src/eventsource/adapters/memory/store.py if you need to confirm
current OpenTelemetry integration before relying on any tracing hook here.
Lifecycle: no setup or teardown; state dies with the instance¶
There is nothing to connect, initialize, or close. InMemoryEventStore is not
an async context manager. Construct it, use it, drop the reference; the
events go with it. That makes the cleanest test fixture a fresh instance per
test:
import pytest
from eventsource import InMemoryEventStore
@pytest.fixture
def store() -> InMemoryEventStore:
return InMemoryEventStore()
Prefer a fresh instance per test over trying to reset a shared one.
Caveats¶
Single process only. State lives on one Python object. Two instances
share nothing, and neither do two processes — a pytest-xdist worker, a
subprocess, or a separate web worker sees an empty store. Anything exercising
cross-process behavior (distributed locks, several subscription runners, real
contention) needs SQLite or PostgreSQL.
Nothing is durable, including read_all. The global feed and its
Position counter are in-process state that starts fresh for each new
instance. Projection catch-up, checkpoints, and replay therefore only work
within the lifetime of that instance: a test that restarts a component and
expects to resume from a stored position is not reproducing production
behavior.
Concurrency is guarded by a threading.Lock, not a database. Every
append acquires the same store-wide lock, so concurrent coroutines on one
event loop are safe and ExpectedVersion mismatches raise
OptimisticLockError as they should — a plain threading.Lock is used
(rather than asyncio.Lock) because SyncEventStoreAdapter runs each call in
its own event loop via asyncio.run, and the critical section never awaits.
This lock is not a substitute for a real database's contention behavior; do
not use in-memory timings to reason about throughput.
No outbox. InMemoryEventStore never writes outbox rows. Tests covering
transactional outbox behavior need PostgreSQLEventStore with
outbox_enabled=True.
Versions are counted per stream. A stream is StreamId(aggregate_id,
category); the current version and the optimistic-lock check are computed
from the events under that exact stream identity, so the same aggregate_id
under two different categories gets two independent version sequences.
Appends are rejected on duplicate event_id, not silently skipped. An
event whose event_id is already in the store — from any stream — raises
DuplicateEventError rather than being silently absorbed.
An empty events list raises, it does not short-circuit. store.append(stream,
[], expected=...) raises ValueError("cannot append an empty batch of
events") rather than returning a successful no-op result.
Use SQLiteEventStore for single-node durability¶
SQLiteEventStore needs the sqlite extra:
Import it from eventsource.adapters.sqlite (or the top-level package, where
it is re-exported when aiosqlite is installed):
Construct it directly against a database path — there is no initialize()
call and no async with store: context manager:
def __init__(
self,
database: str,
event_registry: EventRegistry | None = None,
*,
store_id: str | None = None,
wal_mode: bool = True,
busy_timeout: int = 5000,
) -> None: ...
The connection and schema are created lazily, on first call, guarded by an
internal asyncio.Lock so concurrent first-callers do not race each other.
There is nothing to await before use and nothing to close explicitly beyond
normal process shutdown. wal_mode (default True) enables SQLite's
write-ahead log for better read/write concurrency within the single-writer
constraint; busy_timeout controls how long a writer waits on lock
contention before raising.
SQLiteEventStore has no outbox support — outbox_enabled is a
PostgreSQLEventStore-only constructor keyword.
Use PostgreSQLEventStore for production¶
PostgreSQLEventStore needs the postgresql extra:
It takes a plain SQLAlchemy AsyncEngine — not a session_factory or
async_sessionmaker — and builds its own session factory internally:
from sqlalchemy.ext.asyncio import create_async_engine
from eventsource.adapters.postgresql import PostgreSQLEventStore
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/app")
store = PostgreSQLEventStore(engine)
def __init__(
self,
engine: AsyncEngine,
event_registry: EventRegistry | None = None,
*,
store_id: str | None = None,
create_schema: bool = False,
outbox_enabled: bool = False,
) -> None: ...
By default the store assumes the schema (events, and — if
outbox_enabled=True — event_outbox) already exists, applied via the SQL
under src/eventsource/adapters/sql/schemas/schemas/ or your own migration tool. Pass
create_schema=True to have the adapter provision it lazily on first use
instead.
The transactional outbox: outbox_enabled=True¶
PostgreSQLEventStore is the only backend with outbox support. When
outbox_enabled=True, every append call writes a matching row into
event_outbox in the same database transaction as the event insert — the
outbox row and the event row commit or roll back together, which is the
entire point of the transactional outbox pattern:
The outbox reader — the component that drains event_outbox and publishes
to a bus — lives separately in eventsource.ports.outbox (the
OutboxRepository contract) and its adapters
(eventsource.adapters.postgresql.PostgreSQLOutboxRepository,
eventsource.adapters.sqlite.SQLiteOutboxRepository,
eventsource.adapters.memory.InMemoryOutboxRepository); enabling the flag
here only controls whether append writes the row.
Reading the global feed: current_position()¶
current_position() returns the store's current Position | None (an
opaque, ordered, store-scoped token defined in eventsource.ports.positions)
— not an integer. Compare and persist Position values; do not do arithmetic
on them:
position = await store.current_position()
async for envelope in store.read_all(from_position=position):
...
PostgreSQL applies a safe-horizon predicate on read_all/current_position
so that a global_position allocated (but not yet committed) by one
transaction cannot cause a reader to skip a lower position that is still in
flight — this is specific to PostgreSQL's out-of-order commit visibility;
SQLite's single-writer model has no equivalent gap to guard against.
Appending events: the common shape across all three backends¶
Every backend takes the same append(stream, events, expected) call:
from uuid import uuid4
from eventsource import ExpectedVersion, StreamId
stream = StreamId(aggregate_id=uuid4(), category="Order")
result = await store.append(
stream,
[OrderCreated(...)],
expected=ExpectedVersion.no_stream(),
)
ExpectedVersion is a frozen dataclass in eventsource.ports.positions with
classmethod constructors: ExpectedVersion.any_() (skip the check),
ExpectedVersion.no_stream() (stream must not exist yet), ExpectedVersion.stream_exists() (stream must already have events), and
ExpectedVersion.exact(n) (stream must currently have exactly n events). A
mismatch raises OptimisticLockError on every backend.
Reading events¶
store.read_stream(stream, options=None)— anAsyncIterator[EventEnvelope]for one stream.store.read_category(category, options=None)— anAsyncIterator[EventEnvelope]across every stream in a category.store.read_all(from_position=None, options=None)— the store's global, ordered feed, also anAsyncIterator[EventEnvelope].await store.get_stream_version(stream)— the stream's current version.await store.event_exists(event_id)— whether a specific event id has been stored.
eventsource.ports.store.collect(iterator) is a small helper for draining any
of the async iterators above into a list[EventEnvelope].