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.
| 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.
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.
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.
- 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.