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:
| Connections | Marten writes/s | Celeriant writes/s |
|---|---|---|
| 500 | 42,721 | 80,000 |
| 3,000 | 29,226 | 130,000 |
| 9,000 | 12,666 | 144,655 |
| 12,000 | 901 | 190,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 sourcing | Celeriant |
|---|---|
events table | The append-only log itself |
aggregate_id column + index | Native aggregate addressing; cold aggregates found through per-segment bloom filters, no B-tree to keep in RAM |
| Version column + conditional insert | Expected version on the write, checked by the server |
| Transaction over several streams | A multi-aggregate write, atomic when the aggregates share a shard (Consistency boundaries) |
| Unique constraint for dedup | Idempotent writes via client sequence numbers |
LISTEN / NOTIFY or polling | The watch API |
| Projections in the same database | A read model you project into your read store, which can still be Postgres |
VACUUM and autovacuum tuning | No 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.