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

Both substrates are append-only and auditable, so the decision hinges on operational burden versus replayability. PostgreSQL is one database with SQL balances; event sourcing is an event store plus projection workers that can rebuild history into any shape. The map contrasts the two on the axes that decide it.

Ledger substrate choice PostgreSQL double-entry is low ops with SQL balances and direct reconciliation; event sourcing has higher ops but first-class replay into arbitrary projections. PostgreSQL tables ops: low (one DB) balances = SUM direct reconciliation the default · days to build Event-sourced ops: high (store + workers) replay to any shape eventual projections for replay needs · weeks
PostgreSQL is the default; event sourcing earns its ops tax only when rebuilding history into new shapes is the product.
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.

What “lossy current state” actually costs

The word lossy in the table is the crux of the whole decision, so it is worth making concrete. A relational double-entry ledger records the effect of every billing action but not always its full intent. When you post a $49.00 (4900 minor units) charge as a debit to accounts receivable and a credit to deferred revenue, the two entries tell you the money moved, but they do not by themselves tell you the charge was a proration triggered by a mid-cycle plan change from plan_starter to plan_pro, that it applied a 20% loyalty discount, or that finance later wants it split across two tax jurisdictions. You can carry some of that in extra columns or a JSONB metadata field, but you are guessing today which facts a future projection will need. An event-sourced log sidesteps the guess: the PlanUpgraded and PaymentSucceeded events retain the raw inputs, so a projection written next year can compute a jurisdiction split that nobody modelled when the event was first appended. That is the single capability you are buying, and if you never exercise it you have paid the ops tax for nothing.

A concrete decision threshold

If you want a numeric rule of thumb rather than a philosophy, use this one. Stay on PostgreSQL until at least two of the following are simultaneously true: you are appending more than roughly five to ten million ledger_entries rows per month on a single primary; you have three or more distinct downstream read models (billing balances, ASC 606 revenue schedules, tax authority filings) that each want a different fold over the same facts; or a regulator has told you in writing that you must reproduce a historical statement as it would have been computed at the time, not as a reversing-entry correction after the fact. One of those alone is almost never enough. Write volume alone is solved by monthly range partitioning on ledger_entries and a BRIN index on occurred_at; a single extra read model is solved by a materialized view refreshed on a schedule. It is the combination — high volume and multiple divergent projections and point-in-time replay obligations — that pushes the total cost of the relational workarounds above the fixed cost of running an event store.

Step-by-Step Implementation

The two data shapes differ in where truth lives. In PostgreSQL the entry rows are the truth and balances are aggregates. In event sourcing the event log is the truth and balances are a rebuildable projection. The diagram contrasts the read paths — a direct SUM versus a fold over the log.

Balance read paths PostgreSQL reads a balance as a SUM over entries; event sourcing folds the event log into a projection that can be dropped and rebuilt. Postgres: ledger_entries the truth balance = SUM Event-sourced: event log the truth projection rebuildable balance read
Postgres reads truth directly as a SUM; event sourcing folds the log into a projection that can always be rebuilt.

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.

5. Snapshot before the log gets long

The step most teams skip until it hurts is snapshotting. A projection that folds the entire log from event zero is fine when the log is a few hundred thousand events, but at tens of millions the cold rebuild that once took seconds now takes minutes, and a projection worker that crashes mid-rebuild has to start over. The fix is to periodically persist a snapshot of the read model tagged with the last event’s sequence number — say, the balances for every customer_id as of event 48_215_390 — and on restart load the snapshot and replay only the tail. This keeps rebuild time bounded by the snapshot interval rather than by total history. The discipline that makes snapshots safe is that they must be derived, never authoritative: you must be able to delete every snapshot and still reconstruct identical read models from the log alone. If a snapshot ever contains a fact that is not reproducible from events, you have quietly recreated the two-sources-of-truth problem the whole architecture was meant to avoid.

6. Keep the idempotency guard on the write path, not the projection

A subtle ordering mistake sinks many first event-sourcing attempts: they deduplicate at the projection instead of at append time. If a gateway retries a webhook and you append a second PaymentSucceeded with the same underlying idempotency_key, the log now contains two events for one real-world fact, and every projection you ever build will double-count it forever because the log is immutable. The guard therefore belongs at the boundary where events enter the store — a unique constraint on idempotency_key in the event store, or a check-and-append in a single transaction — so the duplicate is rejected before it becomes a permanent fact. This is the mirror image of the relational ledger, where the same idempotency_key uniqueness protects the balanced transaction insert. In both substrates the rule is identical: the log or the entries must never record the same external event twice, and dedup on read is a patch over a leak you should have sealed on write.

Verification & Testing

The two substrates have parallel headline tests: the trial-balance zero-sum for Postgres, and replay determinism for event sourcing. Both must pass idempotent replay and reconcile 100% on the happy path. The panel pairs each substrate’s test with its equivalent.

Substrate verification tests Postgres asserts trial-balance zero-sum, event sourcing asserts replay determinism, both pass idempotent replay, and both reconcile fully on the happy path. PG trial balance SUM entries = 0 ES replay project twice byte-identical Idempotent event twice balance stable Reconcile synthetic report 100% match
Trial-balance zero-sum and replay determinism are the same guarantee expressed in each substrate's terms.

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

The pitfalls are mostly reasons not to reach for event sourcing prematurely: it is not free auditability, projection lag is a footgun, immutable events trap you in old schemas, and mixing both gives two sources of truth. The map groups them.

Substrate pitfalls Event sourcing is not free audit, projection lag misleads reads, immutable events trap schemas, mixing both duplicates truth, and throughput rarely forces the choice. Not free audit PG already is → don't adopt for it Projection lag seconds behind → pick read model Schema trap events forever → version + upcast Two truths mixed = worst → one source Throughput rarely forces → measure first
Most pitfalls are reasons not to reach for event sourcing early — measure before you pay its ops tax.
  • 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.

The honest summary is that for the large majority of SaaS billing systems, the append-only PostgreSQL ledger is the right default and event sourcing is a deliberate exception justified by a specific need, not a general upgrade. Reach for event sourcing when the intent behind a balance change is itself a product feature — when you must answer “why did this balance change, in the actor’s own words” for every movement, replay historical states for a what-if engine, or feed an audit product that surfaces the decision stream to end users. Those are real requirements, and where they exist the event log earns its operational cost. Absent them, the relational ledger gives you the same immutability and auditability with a fraction of the moving parts, and the engineering hours event sourcing would consume are better spent on reconciliation coverage and monitoring.

Whichever you choose, the decision that actually matters is naming a single system of record and holding the line on it. The worst outcome on this page is not picking the “wrong” model — either can be made correct — but running both as co-authorities and spending forever reconciling an event log against a balance table that are each allowed to be right. Pick one as the truth, derive everything else as a projection you can rebuild from that truth, and the choice between the two models becomes a matter of operational taste rather than a correctness risk. A team that can rebuild every read model from its system of record on demand has bought itself the freedom to change nearly everything else later; a team with two sources of truth has bought a permanent reconciliation tax.

Frequently Asked Questions

Is an append-only table already event sourcing? Partly. An immutable entries table gives you the audit trail; full event sourcing adds derived read models rebuilt from the log, which is more machinery than most billing systems need.

What does event sourcing buy that an immutable table does not? The ability to rebuild any projection from scratch, including ones you had not thought of when the events were written. That is genuinely valuable at scale and overkill for a few million rows.

Does a relational ledger scale far enough? For the overwhelming majority of subscription businesses, yes, with partitioning by period. The constraint is usually query patterns rather than write volume.

What is the migration path if the simple choice runs out? Keep entries immutable and keyed by economic event from the start, and the log you already have is the event stream. Retrofitting immutability later is the hard part, not adding projections.