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.