PostgreSQL vs Event-Sourced Ledger for Billing
Once you have committed to an immutable ledger, the next fork in the road is how immutable it is implemented: a relational double-entry model in PostgreSQL, or an event-sourced ledger that stores an append-only log of domain events and derives balances from projections. Both are append-only and both can be perfectly auditable, so the decision is really about operational burden, query ergonomics, replayability, and throughput. This page is the decision companion to Reconciliation & Double-Entry Ledger, and it assumes you have read how the immutable PostgreSQL double-entry ledger is built.
The short version: PostgreSQL double-entry tables are the right default for the overwhelming majority of SaaS billing systems. Reach for event sourcing only when you have genuine multi-projection needs, regulatory replay requirements, or write volumes that a single relational ledger struggles with β and accept the operational complexity that comes with it.
Trade-offs
| Dimension | PostgreSQL double-entry tables | Event-sourced ledger (event log + projections) |
|---|---|---|
| Source of truth | The ledger_entries rows themselves |
The append-only event log; balances are derived |
| Auditability | Strong β every entry is immutable and timestamped | Strong β every state change is a stored event with full intent |
| Query complexity | Low β balances are SQL SUM/GROUP BY |
Higher β you query projections, not the log; ad-hoc queries need a new projection |
| Replay / rebuild | Limited β you have the result, not the intent; corrections are reversing entries | First-class β rebuild any projection by replaying events from zero |
| Throughput | Thousands of txns/sec on one Postgres node; scale via partitioning | Higher write ceiling; log appends shard well, projections scale independently |
| Read latency | Fast for current balances; pre-aggregate hot accounts | Fast reads from projections; eventual consistency lag (msβseconds) |
| Consistency model | Strong β ACID transaction wraps all legs | Strong on the log; projections are eventually consistent |
| Operational burden | Low β one database your team already runs | High β event store, projection workers, schema/versioning, snapshotting |
| Schema evolution | ALTER TABLE migrations |
Event versioning/upcasting; old events are immutable forever |
| Reconciliation fit | Direct β match settlements to entries by external_ref |
Indirect β reconcile a projection that mirrors the entry shape |
| Time to first version | Days | Weeks |
The decisive columns are usually operational burden and query complexity. A relational ledger is one database, ad-hoc balance queries are one SUM, and reconciliation joins directly to gateway settlements. Event sourcing wins specifically when you need to rebuild history into new shapes β a new tax projection, a new revenue-recognition view β without re-deriving from a lossy current state.
Step-by-Step Implementation
1. Start from the relational ledger if requirements are ordinary
If you need a billing source of truth, balances, and reconciliation β and not arbitrary historical re-projection β use the double-entry tables. One balanced transaction per event, append-only entries, balances by aggregation.
-- Current balance: one query, no projection workers to operate
SELECT a.code, SUM(e.amount) AS balance_minor_units
FROM ledger_entries e
JOIN ledger_accounts a USING (ledger_account_id)
GROUP BY a.code;
2. Choose event sourcing when intent and replay are the product
Model each billing fact as an immutable event carrying full intent. Balances become a projection you can rebuild at will.
from dataclasses import dataclass
@dataclass(frozen=True)
class PaymentSucceeded:
event_id: str
customer_id: str
invoice_id: str
amount_minor: int # signed minor units
currency: str
occurred_at: str
# Append-only log; never mutated. external_ref is preserved for reconciliation.
def append(store, event) -> None:
store.append({"type": type(event).__name__, **event.__dict__}) # β
immutable fact
3. Build projections to make balances queryable
A projection folds events into a read model. The key property: you can drop it and rebuild from the log.
def project_balances(events) -> dict[str, int]:
balances: dict[str, int] = {}
for e in events: # replay from zero
if e["type"] == "PaymentSucceeded":
balances[e["customer_id"]] = balances.get(e["customer_id"], 0) + e["amount_minor"]
elif e["type"] == "RefundIssued":
balances[e["customer_id"]] = balances.get(e["customer_id"], 0) - e["amount_minor"]
return balances # β
rebuildable read model
4. Keep reconciliation identical at the boundary
Whichever you pick, reconciliation against gateway settlements is the same problem: match an external reference and assert amount equality. With Postgres you match against ledger_entries; with event sourcing you match against a projection shaped like entries, carrying external_ref from the original event. Build that projection regardless, so reconciliation logic does not have to know which substrate it runs on.
Verification & Testing
For the relational ledger, your strongest test is the trial balance: SELECT SUM(amount) FROM ledger_entries must be 0 after any fixture. For event sourcing, the equivalent is replay determinism β project the same event log twice and assert byte-identical read models, and assert that a projection rebuilt from zero matches the incrementally-maintained one. Both designs should pass an idempotent-replay test: feeding the same gateway event twice (via the idempotency guard) must not change balances. Always reconcile the chosen store against a synthetic settlement report and assert 100% match on the happy path.
Gotchas & Production Pitfalls
- Event sourcing is not free auditability. A well-built PostgreSQL ledger with append-only entries is already fully auditable. Do not adopt event sourcing only for audit β you pay the operational tax without a corresponding gain.
- Projection lag is a consistency footgun. If a user-facing balance reads a projection that is seconds behind the log, a just-posted refund may appear missing. Decide explicitly which reads tolerate eventual consistency.
- Immutable events trap you in old schemas. Once an event is written you can never change it; you must version event types and upcast on read. Underestimating this versioning burden is the classic event-sourcing regret.
- Mixing the two halfway is worst-of-both. An event log that is also the system of record and a Postgres balance table that is also authoritative gives you two sources of truth to reconcile. Pick one source of truth; let the other be a derived projection.
- Throughput rarely forces the choice. A partitioned Postgres ledger handles low-millions of entries per month easily. If you are choosing event sourcing for throughput, measure first β most SaaS billing never approaches the relational ceiling.