Set Up the Database Schema¶
The PostgreSQL and SQLite backends do not create their own tables. Before
PostgreSQLEventStore, SQLiteEventStore, or any of the outbox, checkpoint, DLQ,
and snapshot repositories will work, the tables they read and write have to
exist. eventsource.adapters.sql.schemas ships the SQL that creates them, plus helpers for
loading that SQL from the installed package instead of copying it into your repo.
This guide shows you how to:
- Apply the bundled combined schema with
psqlor thesqlite3CLI. - Apply it from Python with
get_all_schemas()over a SQLAlchemy async engine or anaiosqliteconnection. - Create only the tables you need with
get_schema(name, backend=...), and see what exists withlist_schemas()andlist_backends(). - Generate an Alembic revision from the bundled templates with
get_alembic_template()and fold it into an existing migration chain. - Opt into the partitioned
eventstable on PostgreSQL. - Apply the versioned SQL in
updates/to a database created by an earlier version of the library.
Pick exactly one of the three application options. They all produce the same tables, so running two of them against the same database gives you either duplicate-object errors or an Alembic chain that disagrees with what is actually in the database.
Everything here is per-backend: postgresql is the default for every helper, and
sqlite is the only other backend with bundled templates. Some tables
(events_partitioned, migration) are PostgreSQL-only, so a SQLite setup is a
strict subset.
Before you start¶
You need:
- Python 3.13 or newer with
eventsource-pyinstalled. The SQL and Alembic templates ship inside theeventsource.adapters.sql.schemaspackage, so the helpers read them out of your site-packages -- there is nothing to download or vendor. - A running database you can create tables in. PostgreSQL 12 or newer for the PostgreSQL schema, SQLite 3.8 or newer for the SQLite schema.
- A driver for the backend you are targeting, if you intend to apply the
schema from Python:
pip install "eventsource-py[postgresql]"forasyncpg, orpip install "eventsource-py[sqlite]"foraiosqlite. The core install brings inpydanticandsqlalchemyonly. If you are applying the SQL withpsqlor thesqlite3CLI instead, you do not need either driver yet -- but the event store will need one at runtime. - Alembic, only if you are taking Option 3. It is not a dependency of this library; install it yourself.
On PostgreSQL, check two things before you run anything:
- The combined schema starts with
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";. Creating an extension requires elevated privileges on most managed PostgreSQL services. If the extension already exists in the database, theIF NOT EXISTSguard makes the statement a no-op and an unprivileged role can run the rest of the file; if it does not, have a superuser or the service's extension-management console install it first. - The whole combined file runs inside a single
BEGIN; ... COMMIT;, so it either applies completely or not at all. EveryCREATEin it isIF NOT EXISTSguarded, which makes a full re-run safe but also means it will silently leave an existing, differently-shaped table alone rather than correcting it.
Decide up front which tables you actually need. The combined schema creates all
five (events, event_outbox, projection_checkpoints, dead_letter_queue,
snapshots). If you are only using an event store with no outbox, projections,
or snapshots, skip ahead to get_schema(name, backend=...) and create just
events.
Finally, know which application option you are committing to. If your project already has an Alembic chain, take Option 3 -- applying the raw SQL first and adding an Alembic revision later leaves Alembic believing it created tables it did not.
Which tables eventsource needs¶
Nothing in the library creates tables on the fly, and no component checks for a table it does not use. Create only the tables whose components you actually construct.
| Table | Created by | Needed by | Backends |
|---|---|---|---|
events |
get_schema("events") |
PostgreSQLEventStore, SQLiteEventStore |
postgresql, sqlite |
event_outbox |
get_schema("outbox") |
PostgreSQLOutboxRepository, SQLiteOutboxRepository, and the event stores when you append and enqueue in one transaction |
postgresql, sqlite |
projection_checkpoints |
get_schema("checkpoints") |
PostgreSQLCheckpointRepository, SQLiteCheckpointRepository -- resumable projections and subscriptions |
postgresql, sqlite |
dead_letter_queue |
get_schema("dlq") |
PostgreSQLDLQRepository, SQLiteDLQRepository |
postgresql, sqlite |
snapshots |
get_schema("snapshots") |
PostgreSQLSnapshotStore; SQLiteSnapshotStore applies the sqlite variant itself on connect |
postgresql, sqlite |
events (partitioned) |
get_schema("events_partitioned") |
drop-in replacement for events on high-volume PostgreSQL |
postgresql only |
tenant_migrations, tenant_routing, migration_position_mappings, migration_audit_log |
get_schema("migration") |
PostgreSQLMigrationRepository -- live store-to-store migration tooling |
postgresql only |
The combined schema (get_all_schemas(), schemas/all.sql,
schemas/sqlite_all.sql) creates the first five rows only. The partitioned
events variant and the four migration tables are not in it -- if you want
either, apply their templates yourself.
What each table carries¶
eventsis the source of truth. It is keyed by a monotonicglobal_position(BIGSERIALon PostgreSQL,INTEGER PRIMARY KEY AUTOINCREMENTon SQLite) and enforcesUNIQUE (aggregate_id, aggregate_type, version). That constraint is what turns a concurrent append into anOptimisticLockErrorrather than a corrupt stream, so do not drop it.tenant_idandactor_idare nullable -- leavetenant_idNULL for single-tenant deployments.event_outboxholds rows written in the same transaction as the append so a publisher can deliver them afterwards.statusis constrained topending,published, orfailed, and most of its indexes are partial ones overstatus = 'pending'.projection_checkpointsis one row per projection name, trackingglobal_position,last_event_id, andevents_processed. Without it a projection restarts from the beginning of the stream every time. On PostgreSQL the schema also installs anupdate_checkpoint_timestamp()trigger function that maintainsupdated_at.dead_letter_queuestores events a projection gave up on, unique per(event_id, projection_name), withstatusinfailed,retrying, orresolved.snapshotsstores at most one row per(aggregate_id, aggregate_type)-- it is a cache in front of stream replay, not history. Deleting its contents is always safe; aggregates simply rebuild fromevents.
Backend differences worth knowing¶
The SQLite schema is a straight subset of the PostgreSQL one, with the same
table and column names but different types: TEXT in place of UUID and
VARCHAR, TEXT timestamps in place of TIMESTAMP WITH TIME ZONE, and TEXT
payloads in place of JSONB. SQLite also gets no CHECK constraints on
status, no COMMENT ON TABLE, and no trigger -- application code enforces
what the PostgreSQL schema enforces in the database.
Two schemas have no SQLite counterpart at all: events_partitioned and
migration. Asking for them with backend="sqlite" raises ValueError, which
is the same error you get for any name outside list_schemas("sqlite").
If you are unsure, start with the event store only¶
A first cut needs events and nothing else. Add projection_checkpoints when
you start running projections, dead_letter_queue when those projections need
somewhere to park poison events, event_outbox when you move to transactional
publishing, and snapshots when replay latency becomes a problem. Each is a
separate template, so adding one later means applying one more file -- there is
no ordering dependency between them and no foreign keys tying them together.
Option 1: Apply the bundled schema with a CLI client¶
This is the option to take when the database is set up once, by hand or by a provisioning script, and your project has no Alembic chain to fold into. You run one file and you are done.
The two combined files ship inside the installed package, at
eventsource/adapters/sql/schemas/schemas/all.sql (PostgreSQL) and
eventsource/adapters/sql/schemas/schemas/sqlite_all.sql (SQLite). You do not have to
guess where site-packages put them -- ask Python:
python -c "from importlib.resources import files; print(files('eventsource.adapters.sql.schemas').joinpath('schemas/all.sql'))"
That prints an absolute path you can hand to psql -f. If you would rather not
touch the filesystem at all, print the SQL to stdout with get_all_schemas()
and pipe it into the client; both approaches are shown below.
Both files create the same five tables -- events, event_outbox,
projection_checkpoints, dead_letter_queue, snapshots -- and nothing else.
The partitioned events variant and the migration tables are separate
templates; see the partitioning section below if you need them.
PostgreSQL: psql and schemas/all.sql¶
Point psql at the packaged file. Always pass -v ON_ERROR_STOP=1: without it
psql reports an error and keeps going, and because the file wraps everything
in BEGIN; ... COMMIT; you can end up watching a long cascade of "current
transaction is aborted" messages scroll past a failure you have already missed.
SCHEMA=$(python -c "from importlib.resources import files; print(files('eventsource.adapters.sql.schemas').joinpath('schemas/all.sql'))")
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$SCHEMA"
Or pipe the SQL straight from the helper, which avoids resolving a path and works identically when the package is installed inside a container image:
python -c "from eventsource.adapters.sql.schemas import get_all_schemas; print(get_all_schemas())" \
| psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f -
Expect output ending in COMMIT, with CREATE TABLE / CREATE INDEX /
COMMENT lines above it.
The file is safe to re-run end to end. Every CREATE TABLE and CREATE INDEX
is IF NOT EXISTS guarded, update_checkpoint_timestamp() is CREATE OR
REPLACE FUNCTION, and the one statement PostgreSQL will not let you guard --
CREATE TRIGGER trg_checkpoint_updated_at on projection_checkpoints -- is
preceded by an explicit DROP TRIGGER IF EXISTS trg_checkpoint_updated_at ON
projection_checkpoints;. A second pass re-creates the trigger and changes
nothing else.
That idempotence is about object existence, not object shape. If an events
table already exists with different columns, CREATE TABLE IF NOT EXISTS skips
it silently and you get no error at all -- the mismatch surfaces much later as a
runtime failure from the event store. When you are applying the schema to a
database that is not empty, inspect what is already there before you run the
file.
The one thing that commonly fails on a first run is the extension:
ERROR: permission denied to create extension "uuid-ossp"
HINT: Must be superuser to create this extension.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; is the first statement in the
file and it sits before the BEGIN;, so it is the only thing that fails --
nothing else has been applied yet, and with ON_ERROR_STOP=1 psql exits right
there. Have a superuser, or your provider's extension console, install the
extension once; then re-run the file as your normal role and the IF NOT
EXISTS guard turns that statement into a no-op.
Everything after the extension runs inside a single BEGIN; ... COMMIT;. If
any statement in that block fails, the whole transaction rolls back and you are
left with no partial schema to clean up.
The tail of all.sql carries a commented-out verification query. Run it to
confirm all five tables landed:
psql "$DATABASE_URL" -c "
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('events', 'event_outbox', 'projection_checkpoints', 'dead_letter_queue', 'snapshots')
ORDER BY table_name;"
Five rows, all BASE TABLE. Fewer than five means the transaction rolled back
or you ran a per-table template instead of the combined file.
SQLite: sqlite3 and schemas/sqlite_all.sql¶
Same shape, different file. The SQLite schema has no transaction wrapper of its
own -- no BEGIN;/COMMIT; -- so sqlite3 applies each statement as it reads
it. A failure partway through leaves the tables created up to that point.
SCHEMA=$(python -c "from importlib.resources import files; print(files('eventsource.adapters.sql.schemas').joinpath('schemas/sqlite_all.sql'))")
sqlite3 events.db < "$SCHEMA"
Or pipe the SQL from the helper, passing the backend explicitly -- every
migrations helper defaults to postgresql, so omitting backend="sqlite" gets
you the PostgreSQL file:
python -c "from eventsource.adapters.sql.schemas import get_all_schemas; print(get_all_schemas(backend='sqlite'))" \
| sqlite3 events.db
The sqlite3 CLI reads a whole multi-statement script from stdin without
complaint -- the one-statement-per-call limitation belongs to aiosqlite's
execute(), not to the CLI, and is covered in the troubleshooting section.
Redirecting into sqlite3 events.db creates the database file if it does not
exist, so there is no separate "create the database" step.
Successful output is silence. Confirm with:
which should list dead_letter_queue, event_outbox, events,
projection_checkpoints, snapshots, and the sqlite_sequence table SQLite
maintains for the AUTOINCREMENT primary keys.
Like the PostgreSQL file, this one is safe to re-run: every CREATE TABLE and
CREATE INDEX is IF NOT EXISTS guarded, and the updated_at trigger on
projection_checkpoints is written as CREATE TRIGGER IF NOT EXISTS
trg_checkpoint_updated_at. And as on PostgreSQL, that idempotence is about
existence, not shape -- an existing events table with different columns is
skipped silently, and the mismatch only shows up later as a runtime error.
The tail of sqlite_all.sql carries a commented-out verification query if you
want more than .tables:
sqlite3 events.db "
SELECT name, type
FROM sqlite_master
WHERE type = 'table'
AND name IN ('events', 'event_outbox', 'projection_checkpoints', 'dead_letter_queue', 'snapshots')
ORDER BY name;"
Five rows. Fewer means a statement failed partway through -- scroll back for the error, since without a surrounding transaction nothing rolled back.
Two differences from the PostgreSQL run are worth knowing before you go further:
- There is no
CREATE EXTENSIONstep, so none of the privilege problems from the PostgreSQL section apply. SQLite generates its own row ids throughINTEGER PRIMARY KEY AUTOINCREMENT; UUIDs come from application code. - Two schemas have no SQLite file at all:
events_partitionedandmigration. If you need partitioned events or the live-migration tables, you need PostgreSQL.
Do not run the PostgreSQL file against SQLite or the reverse. sqlite3 rejects
BIGSERIAL, JSONB, and COMMENT ON; psql rejects AUTOINCREMENT and
datetime('now') defaults.
Option 2: Apply the schema programmatically¶
Take this option when the schema should be created by your own code rather than
by a person at a shell: test fixtures that need a throwaway database, a
container entrypoint that runs before the app serves traffic, or a
bootstrap/init-db subcommand you ship with the service. Everything Option 1
does with psql and sqlite3 is reachable from Python through
eventsource.adapters.sql.schemas, which reads the same SQL files out of the installed
package.
The helpers only load SQL -- none of them touch a database. You choose the driver and the transaction. That split is why the rest of this section is mostly about how each driver wants to be handed a multi-statement script, which is the one place this gets fiddly.
Load the combined schema with get_all_schemas()¶
get_all_schemas() returns the contents of the combined file as a string:
from eventsource.adapters.sql.schemas import get_all_schemas
pg_sql = get_all_schemas() # schemas/all.sql
sqlite_sql = get_all_schemas(backend="sqlite") # schemas/sqlite_all.sql
backend defaults to "postgresql" on this and every other helper in the
module, so the SQLite call must pass it explicitly. It is a thin wrapper around
get_schema("all", backend=backend) -- same file, same string.
What you get back is the same five-table script described in Option 1:
events, event_outbox, projection_checkpoints, dead_letter_queue,
snapshots. The PostgreSQL string leads with CREATE EXTENSION IF NOT EXISTS
"uuid-ossp"; and wraps the rest in BEGIN; ... COMMIT;. Keep that in mind
before you wrap the call in a transaction of your own: on PostgreSQL the script
already manages one.
Apply it over a SQLAlchemy async engine (PostgreSQL)¶
This is where the obvious code does not work. The asyncpg dialect always
routes statements through a prepared statement, and PostgreSQL refuses to
prepare more than one command at a time. So the example that looks right --
# Does NOT work with the asyncpg driver.
async with engine.begin() as conn:
await conn.execute(text(get_all_schemas()))
-- fails with asyncpg.exceptions.PostgresSyntaxError: cannot insert multiple
commands into a prepared statement. The combined schema is forty-odd
statements.
Reach past SQLAlchemy to the raw asyncpg connection, whose execute() uses
the simple query protocol and accepts a whole script:
from sqlalchemy.ext.asyncio import create_async_engine
from eventsource.adapters.sql.schemas import get_all_schemas
async def create_schema(dsn: str) -> None:
engine = create_async_engine(dsn)
try:
async with engine.begin() as conn:
raw = await conn.get_raw_connection()
await raw.driver_connection.execute(get_all_schemas())
finally:
await engine.dispose()
get_raw_connection() gives you SQLAlchemy's pooled wrapper;
.driver_connection is the underlying asyncpg.Connection. Because the script
carries its own BEGIN; ... COMMIT;, the surrounding engine.begin() block is
there for connection handling rather than atomicity -- the script's own
transaction is what makes the schema all-or-nothing.
If you have no engine to begin with, skip SQLAlchemy entirely:
import asyncpg
from eventsource.adapters.sql.schemas import get_all_schemas
conn = await asyncpg.connect(dsn)
try:
await conn.execute(get_all_schemas())
finally:
await conn.close()
Do not try to work around the prepared-statement limit by splitting the script
on ;. The PostgreSQL templates define PL/pgSQL functions whose bodies are
dollar-quoted and contain their own BEGIN, END;, and internal semicolons --
update_checkpoint_timestamp() in checkpoints.sql, and a dozen more in
outbox.sql and dlq.sql. A naive split cuts those function bodies in half.
If you genuinely need statement-at-a-time execution (some connection poolers in
transaction mode will force this on you), keep a hand-maintained list of
statements the way tests/integration/conftest.py does rather than deriving one
from the packaged SQL.
Errors surface as they do in Option 1: a privilege error on CREATE EXTENSION
arrives as asyncpg.exceptions.InsufficientPrivilegeError before any table is
created, and re-running against a database that already has the schema is a
no-op, because every CREATE is IF NOT EXISTS guarded and the one trigger is
preceded by DROP TRIGGER IF EXISTS.
Apply it with aiosqlite (SQLite)¶
aiosqlite has the mirror-image restriction and a purpose-built escape hatch.
execute() raises sqlite3.ProgrammingError: You can only execute one statement
at a time.; executescript() takes the whole file:
import aiosqlite
from eventsource.adapters.sql.schemas import get_all_schemas
async def create_schema(path: str = "events.db") -> None:
async with aiosqlite.connect(path) as db:
await db.executescript(get_all_schemas(backend="sqlite"))
await db.commit()
connect() creates the file if it is missing, and ":memory:" gives you a
fresh schema per test with no cleanup. The SQLite script has no BEGIN;/
COMMIT; of its own -- executescript() issues an implicit COMMIT before it
starts and then runs the statements, so a failure partway through leaves the
tables created up to that point in place. Since every statement is IF NOT
EXISTS guarded (including CREATE TRIGGER IF NOT EXISTS
trg_checkpoint_updated_at), the fix for a partial apply is simply to run it
again.
Over a SQLAlchemy aiosqlite engine, the same driver rule applies -- go to the
raw connection and call executescript:
async with engine.begin() as conn:
raw = await conn.get_raw_connection()
await raw.driver_connection.executescript(get_all_schemas(backend="sqlite"))
Verify the result from the same connection rather than shelling out:
async with db.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
) as cursor:
print([row[0] for row in await cursor.fetchall()])
# ['dead_letter_queue', 'event_outbox', 'events',
# 'projection_checkpoints', 'snapshots', 'sqlite_sequence']
sqlite_sequence is SQLite's own bookkeeping table for the AUTOINCREMENT
primary keys, not one of ours.
Create tables selectively with get_schema(name, backend=...)¶
get_schema() loads one template instead of the combined file. Use it when you
want an event store and nothing else, or when you want a table the combined
schema does not include:
from eventsource.adapters.sql.schemas import get_schema
events_sql = get_schema("events")
events_sql_lite = get_schema("events", backend="sqlite")
partitioned = get_schema("events_partitioned") # PostgreSQL only
migration_tables = get_schema("migration") # PostgreSQL only
Valid names are events, events_partitioned, outbox, checkpoints, dlq,
snapshots, migration, and all. Passing "all" routes to the combined file,
which is exactly what get_all_schemas() does.
Apply the strings the same way as the combined script -- one asyncpg.execute()
or one executescript() per template. There are no foreign keys between the
tables and no ordering constraints, so apply only what you need, in any order:
Two differences from the combined file are worth planning around:
- No
CREATE EXTENSION. Theuuid-osspline lives only inschemas/all.sql. Per-table templates never create extensions, which is convenient on managed PostgreSQL where you may not have the privilege -- the library generates UUIDs in Python, so nothing at runtime depends on the extension. - No transaction wrapper. The
BEGIN;/COMMIT;pair is also only in the combined file. If you want several per-table templates to land atomically, open the transaction yourself, or concatenate the templates and send them as one script (asyncpg's simple-query protocol wraps a multi-statement script in an implicit transaction).
Beyond the five tables in the combined schema, get_schema() is the only way to
reach events_partitioned (a drop-in replacement for events, covered below)
and migration (the four tables behind PostgreSQLMigrationRepository). Both
are PostgreSQL-only.
If you would rather hand a path to an external tool than pass a string around,
get_template_path(name, backend=...) returns the Path to the same file
without reading it.
Discover what is available: list_schemas() and list_backends()¶
Two helpers let you enumerate rather than hard-code:
from eventsource.adapters.sql.schemas import list_backends, list_schemas
list_backends()
# ['postgresql', 'sqlite']
list_schemas()
# ['checkpoints', 'dlq', 'events', 'events_partitioned',
# 'migration', 'outbox', 'snapshots']
list_schemas("sqlite")
# ['checkpoints', 'dlq', 'events', 'outbox', 'snapshots']
Both return sorted lists derived from the .sql files actually present in the
installed package, so they cannot drift from what get_schema() will accept.
The difference between the two list_schemas() results is the whole story of
the backend gap: events_partitioned and migration are PostgreSQL-only.
Neither list includes all, even though get_schema("all") works -- the
combined files live in schemas/, not templates/, and these helpers only scan
templates/.
list_schemas() is what makes the ValueError for a missing SQLite template
actionable, since the error message embeds the available list:
get_schema("events_partitioned", backend="sqlite")
# ValueError: Schema 'events_partitioned' is not available for backend
# 'sqlite'. Available schemas: ['checkpoints', 'dlq', 'events', 'outbox',
# 'snapshots']
A misspelled name under backend="postgresql" raises FileNotFoundError
instead -- there is no fallback backend to compare against. See the
troubleshooting section for both.
A useful pattern in setup code that supports both backends is to intersect what you want with what the backend has: