Skip to main content

Coming from Postgres

You already did the hard part. You write events first, the events are your source of truth, and you project state from them: an events table, a version column for optimistic concurrency, indexes on aggregate id and event type, maybe Marten on top. The model is right. Celeriant keeps it and replaces the engine underneath.

Where it breaks

The conditional append that enforces your invariant is the operation Postgres struggles to do under concurrency. Marten on Postgres 17, primary plus synchronous standby, measured on the same hardware as Celeriant, one event per request:

ConnectionsMarten writes/sCeleriant writes/s
50042,72180,000
3,00029,226130,000
9,00012,666144,655
12,000901190,647

Postgres is competitive at low concurrency. Then it falls off a cliff. Each append is a transaction that inserts the event, bumps the stream's version row, and waits for the WAL flush on both nodes. Every connection is an OS process, every writer queues on the WAL insert lock, the version row is a row lock, and every write leaves tuple versions for autovacuum. See Performance.

There is a correctness trap too. A bigserial position is allocated before commit, so transactions commit out of sequence order, and a projection that catches up with WHERE seq > last_seen can skip an event that commits late.

Celeriant is thread-per-core with one log per shard, Direct I/O, and batched fdatasync and replication. Positions are per aggregate and assigned in commit order. Memory is bounded by a cache budget, not by how many aggregates you have.

What maps to what

Postgres event sourcingCeleriant
events tableThe append-only log itself
aggregate_id column + indexNative aggregate addressing; cold aggregates found through per-segment bloom filters, no B-tree to keep in RAM
Version column + conditional insertExpected version on the write, checked by the server
Transaction over several streamsA multi-aggregate write, atomic when the aggregates share a shard (Consistency boundaries)
Unique constraint for dedupIdempotent writes via client sequence numbers
LISTEN / NOTIFY or pollingThe watch API
Projections in the same databaseA read model you project into your read store, which can still be Postgres
VACUUM and autovacuum tuningNo updates, so no dead tuples; compaction only reclaims space you trimmed or deleted

What you gain, what you give up

Gain: throughput that holds as connections climb, durability that does not depend on page-cache writeback, and aggregate counts that do not blow out an index.

Give up: SQL on the write store. Reads are per aggregate, by version range and event type. Keep Postgres on the read side, where it is a good projection target. You also give up cross-entity transactional reads: per-aggregate reads stay current with just-in-time catch-up, but one consistent snapshot over many rows is what Postgres is for. See when not to use it.

Next: the Quickstart, or Optimistic concurrency.