- Introduction — A 600-Year-Old Bug Preventer
- Why the Naive Design Loses Money
- Debits and Credits — Every Transaction Is Recorded Twice
- Immutable, Append-Only Records
- Modeling Accounts and Balances
- "Why Not Just One Balance Column?"
- Reconciliation — Confirming the Ledger Is True
- Consistency Under Concurrency
- TigerBeetle — A Purpose-Built Database for Ledgers
- Conclusion
- References
Introduction — A 600-Year-Old Bug Preventer
If you build a payment system, you will inevitably hit this question: "How do I store the user's balance?" The most naive answer is "put a balance column on the accounts table, add when money comes in, subtract when it goes out." But this naive design quietly and reliably loses money in a real service.
People who handle money solved this problem a very long time ago. It is double-entry bookkeeping, systematized by fifteenth-century Italian merchants and the mathematician Luca Pacioli. Six hundred years later, the ledgers of banks, card companies, and fintechs still run on this principle. The reason is simple: double-entry carries within itself a structure that catches errors.
This post treats the double-entry ledger from a software engineer's point of view. We will look at why debits and credits must always be equal, why we never modify or delete records but only append (append-only), how to model accounts and balances, why the temptation of "just one balance column" is dangerous, how to secure reconciliation and consistency under concurrency, and why a purpose-built ledger database like TigerBeetle appeared. You can run the schemas and queries here in this site's SQL Playground and Postgres Playground to build intuition.
Why the Naive Design Loses Money
First let us look concretely at why the "one balance column" design is dangerous. Imagine this table.
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance BIGINT NOT NULL -- store the current balance as a single number
);
And suppose a transfer is handled like this.
UPDATE accounts SET balance = balance - 1000 WHERE id = 1; -- the sender
UPDATE accounts SET balance = balance + 1000 WHERE id = 2; -- the receiver
It looks fine on the surface. But this design has three fatal weaknesses.
First, history disappears. Only a single balance number remains, so you cannot know "how this balance became this value." There is no way to trace what transactions there were, when and how much moved. In accounting this is fatal, because it is a system that cannot be audited.
Second, it is vulnerable to partial failure. If the process dies between the two UPDATEs above, you end up in a state where money left one side but did not arrive at the other. Money evaporates into thin air. Wrapping them in a transaction prevents this particular problem, but that alone leaves the problem below.
Third, there is no way to verify. When a balance is wrong, you cannot even notice that it is wrong, because there is no reference to compare against. Even if a bug or race condition makes a balance drift subtly, the system just keeps carrying the wrong number as if it were the truth.
The double-entry ledger fundamentally solves all three. The key shift in thinking is this: do not store the balance; store the transactions. Compute the balance from the transactions.
Debits and Credits — Every Transaction Is Recorded Twice
The name of double-entry holds the answer. Every transaction is recorded twice — once as a debit and once as a credit. And within a single transaction, the sum of debits and the sum of credits must always be exactly equal.
This rule, "sum of debits equals sum of credits," is the heart of double-entry. Money does not come from nothing or vanish into nothing. If it comes out of somewhere, it must go into somewhere. The way accounting enforces this conservation law is precisely the balance of debits and credits.
For example, consider a transaction where A sends 1000 to B. This is one transaction, expressed as two entry lines.
Transaction T1: transfer 1000 from A -> B
Account debit credit
------- ----- ------
A - 1000 (1000 leaves A)
B 1000 - (1000 enters B)
------- ----- ------
Total 1000 1000 <- sum of debits == sum of credits (balanced!)
Here the direction of the terms matters. In accounting, debit/credit does not simply map to "increase/decrease"; its meaning depends on the account type (asset, liability, and so on). But for an engineer designing a software ledger, the crux is this one thing: every transaction consists of multiple entries, and the sum of their debits must equal the sum of their credits. Enforcing this invariant in code is the first step of a double-entry ledger.
The beauty of this rule is self-verification. If you add up every entry in the whole system, the grand total of debits and the grand total of credits must always be equal. If they are not? It means there is a bug somewhere. Simply detecting the moment this equality breaks lets you catch money-leaking bugs early.
Immutable, Append-Only Records
The second pillar of double-entry is immutability. Once an entry is written to the ledger, it is never modified or deleted. You only append new entries.
Why? Because an accounting record is a historical fact about "what happened." What has already happened cannot be changed. If a transaction was wrong, you do not delete its record; you add a new transaction that reverses it. This is called a reversing or adjusting entry.
How to fix a wrong transaction:
(X) UPDATE or DELETE the existing entry <- never do this
(O) append a new transaction that offsets it <- correct
Example: if T1 was wrong
T2: add entries in the opposite direction of T1 to offset it
T3: add the correct transaction anew
-> T1's trace remains intact, and only the final balance becomes correct
This append-only principle grants several powerful properties.
- A complete audit trail. Since the history of every transaction is preserved as-is, any balance can be reconstructed from the beginning to explain "why it is this value." This is an essential property in regulated industries.
- Reproducibility. The balance can be recomputed at any time as the sum of entries. If a balance is in doubt, you re-sum from the start and verify.
- Resistance to race conditions. Instead of read-modify-write on an existing value, you only add a new row, so even when many transactions arrive at once they do not overwrite each other's records.
An append-only ledger converges conceptually with event sourcing. Instead of storing state (the balance) directly, you store the events (transactions) that produced the state, and derive the state from those events. The log of events becomes the source of truth.
Modeling Accounts and Balances
Now let us sketch a real schema. The minimal composition of a double-entry ledger is two tables: accounts and entries.
-- accounts: the logical vessels that hold money
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
currency TEXT NOT NULL -- fix the currency per account
);
-- entries: immutable transaction lines. Only ever INSERTed
CREATE TABLE entries (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT NOT NULL, -- ID that groups entries of the same transaction
account_id BIGINT NOT NULL REFERENCES accounts(id),
direction TEXT NOT NULL, -- 'debit' or 'credit'
amount BIGINT NOT NULL CHECK (amount > 0), -- always positive; sign expressed by direction
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
A few design points.
First, store amounts as integers. Never use floating point. Floating point's property, where 0.1 + 0.2 is not exactly 0.3, is fatal for money. Instead store the currency's smallest unit (cents, for example) as an integer. So 100 USD is the integer 10000 (cents), and so on.
Second, keep amounts always positive and express the sign with the direction. This makes it easy to aggregate the "sum of debits" and the "sum of credits" separately and verify the balance.
Third, there is no balance column. The balance is not a stored value but a value computed from the entries. The balance of a given account is obtained by summing all entries belonging to that account according to their direction.
-- balance of account 42 = (what came into the account) - (what left)
SELECT
COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0)
AS balance
FROM entries
WHERE account_id = 42;
When inserting a transaction, always put the balanced entries in together within a single transaction.
BEGIN;
-- Transaction T1: 1000 from account 1 -> account 2
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (1, 2, 'debit', 1000), -- 1000 enters the receiving account
(1, 1, 'credit', 1000); -- 1000 leaves the sending account
-- the application (or a constraint) verifies debit sum == credit sum for this transaction
COMMIT;
Because these two INSERTs are one transaction, both commit or both roll back. The "only one side applied" partial failure of the naive design becomes fundamentally impossible. If you want to actually run this kind of schema and transaction, you can execute it as-is in the Postgres Playground.
"Why Not Just One Balance Column?"
Let us answer head-on the question many engineers ask here. "Isn't summing all entries every time slow? Why not just keep one balance column and use it like a cache?"
The crux is not treating the balance column as the source of truth. Materializing the balance somewhere for performance is common and reasonable in itself. But it must be a cache derived from the entries. The truth always lives in the append-only log of entries, and the balance cache is merely an aid for reading it quickly.
Why this distinction matters becomes clear when you compare the two designs.
| Aspect | Balance column is truth | Entries are truth (+ balance cache) |
|---|---|---|
| History tracking | Impossible | Complete audit trail |
| Verification | No reference to compare | Re-verify anytime by summing entries |
| Partial failure | Risk of money evaporating | Blocked at the source by transactions |
| When a balance is wrong | Unknowable | Detected and recovered by recomputation |
| Performance | Fast | Fast via cache, truth preserved |
So the answer is not "do not keep a balance column" but "do not mistake the balance column for the truth." Materialize the balance, but you must be able to rebuild it from the entries at any time, and periodically recompute it to verify the cache has not drifted from the truth. This is the decisive difference between a naive design and a robust ledger.
A common pattern in practice is to record a "running balance" per account alongside each entry, while enforcing that this value always equals the previous balance plus the current entry. This gives you fast lookups and verifiability at the same time.
Reconciliation — Confirming the Ledger Is True
A double-entry ledger is structured to help verify itself, but you still need to actually run that verification. This is reconciliation. In the ledger context, reconciliation happens at roughly two levels.
Internal consistency verification. Confirm the ledger itself is obeying its own rules. The most basic is the global equality we saw earlier: adding up every entry in the system, the grand total of debits must equal the grand total of credits.
-- global invariant: sum of all debit entries == sum of all credit entries
SELECT
SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END) AS total_debit,
SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END) AS total_credit
FROM entries;
-- if the two values differ -> an alarm that the ledger is broken. Investigate immediately.
Also, if you use a balance cache, periodically compare the cached balance against the value summed from entries from scratch. If they diverge, recompute the cache and trace the cause.
External reconciliation. Even if the internal ledger is self-consistent, whether it agrees with the outside world (bank accounts, PSP settlement reports) is a separate matter. So you compare the ledger's records against bank statements or PSP settlement files. The reconciliation covered in the earlier payment systems post is exactly this level. If the internal ledger is "the truth we recorded," external reconciliation confirms "whether that truth agrees with actual money."
A well-designed ledger runs both reconciliations automatically every day and observes mismatches as metrics. Clean reconciliation is the strongest evidence that the system is not losing money. And the only reason reconciliation is possible at all is that the ledger preserves every transaction immutably in the first place.
Consistency Under Concurrency
A ledger must maintain consistency even when many transactions arrive at once. Here we touch two representative concurrency problems.
First, the lost update. In the naive balance-column design, if two transactions simultaneously read the balance, each computes on its own, then writes, the later write overwrites the earlier one and one update vanishes. Double-entry's append-only approach is fundamentally resistant to this. Instead of read-modify-write on an existing value, you only add a new row, so even when two transactions arrive at once, each merely inserts its own entry and does not overwrite the other.
Second, the atomic verification of a balance constraint. A constraint like "the balance must not go negative" is tricky under concurrency. If two withdrawals each simultaneously judge "the balance is sufficient right now" and both proceed, together they can exceed the balance. To prevent this, you must serialize access to that account. A common method is to lock that account row (for example, SELECT ... FOR UPDATE) so that only one withdrawal at a time verifies and applies against the balance.
BEGIN;
-- before withdrawing, lock the account to serialize concurrent withdrawals
SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
-- (here, compute account 1's current balance by summing entries and check it is sufficient)
-- if sufficient, INSERT the balanced entries
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (99, 2, 'debit', 500),
(99, 1, 'credit', 500);
COMMIT;
The transaction isolation level also matters. Stronger isolation (serializable, for example) makes the database prevent such anomalies, but with frequent contention it carries a performance cost and retries (on serialization failure). Here again, the principle emphasized in the earlier post continues: for such retries to be safe, transactions must be idempotent, which is why you give each transaction a unique transaction_id to prevent duplicate inserts. Ledgers, idempotency, and consistency interlock this way. If you want to observe how these locks and isolation levels behave, you can experiment in the SQL Playground and Postgres Playground.
TigerBeetle — A Purpose-Built Database for Ledgers
The ledger we have seen so far can be perfectly well implemented on a general-purpose relational database (Postgres, for example). Many fintechs indeed start that way. But when transaction volume grows to an extreme, it becomes hard for a general-purpose database to satisfy performance and consistency at the same time. At this point a new kind of thing appeared: the ledger-specific database. A prominent example is TigerBeetle.
The problem statement TigerBeetle raises is this. A financial ledger's workload is very peculiar. Most of it is the narrow, repetitive operation of "transfer between accounts," each transfer touches multiple accounts at once, and above all it must never be wrong. General-purpose databases are built to handle all sorts of queries well, but precisely because of that generality, they carry large overhead for this peculiar workload.
Summarizing TigerBeetle's design choices from an engineer's point of view.
- Domain-specific. Instead of general SQL, it focuses on two ledger primitives: the "account" and the "transfer." Because the schema is fixed, optimization is possible to an extreme.
- Debits equal credits, enforced by the database. The balance rule is guaranteed by the database core, not the application. An improperly balanced transfer never commits at all.
- Batching for high throughput. It processes transfers in batches rather than individually, targeting hundreds of thousands per second or more.
- Built-in replication and consensus. For the durability of financial data, it embeds consensus-protocol-based replication in the core, so that even if a node dies, data is neither lost nor made inconsistent.
- Deterministic design and brutal testing. Built to behave deterministically, it injects all sorts of faults (disk errors, network partitions) via simulation to verify consistency.
This is not to say you must use TigerBeetle. The key lesson is this: a ledger is such a peculiar and important workload that it is taken seriously enough for a tool to exist solely for it. Most services can start perfectly well with a well-designed double-entry ledger on Postgres, and consider such a dedicated tool when scale grows. Either way, the principle underneath is the very same double-entry bookkeeping from 600 years ago.
Conclusion
The secret of a system that never loses money is not flashy technology but faithfully porting a 600-year-old accounting principle into software. Do not store the balance; store the transactions. Record every transaction twice, as a debit and a credit, and enforce that their sums are always equal. Never modify or delete records; only append. Compute the balance from those entries, and even if you use a balance cache, do not mistake it for the truth.
When these principles work together, the ledger becomes a self-verifying system. The balance of debits and credits reveals errors, the append-only log provides a complete audit trail, reconciliation confirms the truth every day, and transactions and locks maintain consistency even under concurrency. When scale grows to an extreme, consider a dedicated ledger like TigerBeetle, but the underlying principle does not change.
Let me close the payments trilogy with a single sentence that runs through all three posts. A system that handles money must structurally eliminate room for error. Idempotency removes duplicates, double-entry reveals errors, and reconciliation confirms the truth. Only when these three are present together can we say, with confidence, that we have "a system that never loses money."
References
- Martin Fowler: Event Sourcing — https://martinfowler.com/eaaDev/EventSourcing.html
- Modern Treasury: What is double-entry accounting? — https://www.moderntreasury.com/learn/double-entry-accounting
- TigerBeetle: The Financial Transactions Database — https://tigerbeetle.com/
- TigerBeetle Docs: System architecture — https://docs.tigerbeetle.com/coding/system-architecture/
- Square Engineering: Books, an immutable double-entry accounting database — https://developer.squareup.com/blog/books-an-immutable-double-entry-accounting-database-service/
- Wikipedia: Double-entry bookkeeping — https://en.wikipedia.org/wiki/Double-entry_bookkeeping
현재 단락 (1/132)
If you build a payment system, you will inevitably hit this question: "How do I store the user's bal...