Double-entry accounting is a two-hundred-year-old consistency model, and it maps onto a relational database far better than most teams expect — provided you stop treating balances as a column to be updated.
Balances are derived, not stored
A balance is the sum of the entries that produced it. Storing it as a mutable column invites the exact class of concurrent-update bug that double-entry exists to prevent. Store entries; derive balances; materialise the derivation only when read performance demands it.
Atomicity under concurrency
Every transfer writes exactly two rows — a debit and a credit — inside one transaction with a CHECK constraint asserting that the pair sums to zero. Under SERIALIZABLE isolation, PostgreSQL will abort the losing side of a conflict rather than let an inconsistent pair land.
- Constrain the invariant in the schema, not in the service layer.
- Retry serialization failures with jitter; they are expected, not exceptional.
- Partition by account to keep hot-account contention local.
Materialised balances without losing correctness
Incrementally maintained materialised views give you O(1) reads while keeping the entry log as the source of truth. When the view and the log disagree, the log wins and the view rebuilds — a reconciliation job that should run continuously, not monthly.


