Computer Science

Building a double-entry ledger in 200 lines

Every system that tracks value eventually needs a ledger, and most start with a balance column and a sense of optimism. The fix is double-entry bookkeeping: five hundred years old, and small enough to build in an afternoon.

· 8 min read

Editorial cover: a mutable balance loses its history, an append-only ledger keeps it On the left, a database cell labelled balance holds the value forty pounds, with its previous values faded and struck through above it and the note that the history is gone. On the right, a ledger card lists paired entries for each transaction, one negative and one positive, ruled off and summing to zero, with the balance derived beneath. A caption underneath contrasts the two. THE BALANCE COLUMN THE LEDGER £90 £65 balance £40 history: gone correct, possibly same money, different memory ENTRIES (append only) tx-870 world -4000 tx-870 alice +4000 tx-871 alice -100 tx-871 bob +100 0 every transaction proves itself balance(alice) = £39 a fold over history, not a stored fact The balance column stores the conclusion and burns the reasoning. The ledger keeps the reasoning and lets the conclusion be a query.

Sooner or later, every system starts tracking value. Not necessarily money: wallet credits, loyalty points, API quotas, prepaid minutes, whatever gamified beans the product team shipped last quarter. The shape is always the same. Accounts have balances, balances change, and everyone downstream would quite like the numbers to be right.

The first version is always the same too:

ALTER TABLE accounts ADD COLUMN balance INTEGER;

One column. Update it when things happen. Ship it.

This works beautifully, for a while, and then one day a support ticket arrives asking why a customer's balance is £40 when they are absolutely certain it should be £65. You open the database, and the database tells you the balance is £40. That is all it tells you. It has no opinion on how the balance got there, what it was yesterday, or which of the eleven code paths that touch that column is the one that misfired.

You are now doing forensic accounting with no records, which is a discipline better known as guessing.

The fix for this is one of the oldest pieces of technology still in production. Luca Pacioli wrote down the method in Venice in 1494, merchants had been using it for decades before that, and it has survived every platform migration since. It is also, conveniently, small. Like the matching engine, the core of a ledger is a couple of data structures and a few unglamorous rules. Let's build one.

The problem with the balance column

Here is a transfer under the balance-column model:

UPDATE accounts SET balance = balance - 100 WHERE id = 'alice';
UPDATE accounts SET balance = balance + 100 WHERE id = 'bob';

Two writes. Wrap them in a transaction and, on a good day, money moves. But look at what the system knows afterwards: Alice has some number, Bob has some number. The transfer itself, the event that explains both numbers, existed only for the duration of those two statements and then evaporated. The database holds the conclusions and has thrown away the reasoning.

Every question anyone will ever ask about this system is a question about the reasoning. Why did this balance change? When? Which purchase, refund, adjustment, promotion or bug did it? What was the balance at the end of March, when the finance team closed the quarter? A mutable balance can answer none of these, because a mutable balance is a summary that has burned its own sources.

There is a second problem, quieter and worse. When the two updates disagree with reality (a crash between them, a retry that ran twice, a code path that forgot the second write), nothing in the data model even notices. The books don't fail to balance. There are no books.

Write down what happened, not what it means

The ledger's first move is to stop storing conclusions and start storing events. Instead of a balance column, an append-only table of entries:

interface Entry {
  txId: string;      // the transaction this entry belongs to
  account: string;   // whose money moved
  amount: number;    // minor units; negative means it left
  currency: "GBP";
  at: string;        // when it happened
}

Amounts are integers counting the smallest unit of the currency, for reasons covered elsewhere: a pound is not a float, and settlement day is a bad time to rediscover IEEE 754.

You never update an entry and you never delete one. If an entry was wrong, you append a reversing entry that undoes it and a fresh one that does it properly. This feels strange to engineers raised on CRUD, but accountants arrived at immutability five centuries before computers did, and for the same reason we did: an audit trail you can edit is not an audit trail.

Two legs or it didn't happen

Now the actual double-entry rule, which is the whole trick. Value is never created or destroyed in a ledger; it only moves. So every transaction writes at least two entries, and the amounts across a transaction must sum to exactly zero:

tx-871  alice   -100
tx-871  bob     +100
                ────
                   0

Money left Alice, arrived at Bob, and the transaction proves it internally. Even money entering the system from outside gets the same treatment: you model the outside world as an account too, and a customer topping up their wallet is a movement from the world account into theirs. Nothing appears from nowhere, which means nothing can quietly vanish into nowhere either.

The transfer function writes both legs or neither:

function transfer(from: string, to: string, amount: number, txId: string) {
  if (amount <= 0) throw new Error("amounts are positive; direction is the legs' job");
  append([
    { txId, account: from, amount: -amount, currency: "GBP", at: now() },
    { txId, account: to,   amount: +amount, currency: "GBP", at: now() },
  ]); // one atomic append: both legs or neither
}

That zero-sum rule looks like bookkeeping pedantry. It is actually the load-bearing wall. Under the balance-column model, a bug that forgot the second update produced a plausible-looking database. Under double entry, the same bug produces a transaction that doesn't sum to zero, which the append can reject before it ever lands. The invariant turns a silent corruption into a loud error, and loud errors are the cheap kind.

The balance is a question, not a column

So where did the balance go? It became a query:

function balance(account: string): number {
  return entries
    .filter((e) => e.account === account)
    .reduce((sum, e) => sum + e.amount, 0);
}

The balance is a fold over history. It is not stored anywhere; it is derived, on demand, from the full record of everything that ever happened to the account. Ask for the balance as of the end of March and it is the same fold with a date filter. Ask why the balance is £40 and the answer is no longer a shrug; it is a list of every entry that contributed, each one carrying the transaction it belonged to.

Yes, summing an account's entire history on every read gets slow eventually, and yes, real systems cache running balances or periodic snapshots. The distinction that matters is what happens when the cache and the entries disagree: the entries win, every time, and the cache is rebuilt from them. A derived balance can be wrong temporarily. A mutated balance is wrong permanently, and takes the evidence with it.

The part where two requests arrive at once

A ledger meets its first real test the moment two writes race. Alice has £100 and, thanks to a nervous double-click, two £80 transfers arrive within milliseconds of each other. Both read her balance, both see £100, both conclude the money is there, both append. Alice is now £60 overdrawn in a system that doesn't do overdrafts.

The check-then-write pattern is the bug, and the fix is the one payments people reach for in their sleep: make the write itself enforce the rule. Serialise writes per account so the second transfer sees the first one's entries, and let the transaction id do double duty as an idempotency key with a unique constraint on it. The nervous double-click then stops being two transfers and becomes one transfer and one polite replay of its result. The database's constraint machinery does the enforcing, not a well-intentioned if statement upstream of a race.

The trial balance

Because every transaction sums to zero, the entire ledger sums to zero, always. Accountants call checking this the trial balance, and it might be the oldest continuously-run integrity test in the world:

function trialBalance(): boolean {
  return entries.reduce((sum, e) => sum + e.amount, 0) === 0;
}

One line, and it is a global invariant you can verify at any moment, cheaply, forever. If it ever comes back false, something is structurally wrong, and the entries themselves will show you the exact transaction where the books went off. Reconciliation against an external party works the same way one level up: your entries against their statement, differences surfaced rather than suspected.

A ledger that doesn't balance is shouting at you. A balance column that's wrong just sits there, looking confident.

What the 200 lines actually buy

Step back and look at what this tiny thing does. Every number is explained by the records beneath it. Any past state can be reconstructed by replaying to a point in time. Corrections are themselves recorded, so even the mistakes have provenance. When two systems disagree, you can point at the first entry where their stories diverge, which is precisely the property most engineering systems lack when somebody finally asks who changed what, and why.

A chess scoresheet works the same way, and it is the reason the analogy earns its place: nobody writes down the positions, only the moves, because any position can be rebuilt from the moves and a wrong position can be traced to the exact move that caused it. The ledger is a scoresheet for money.

Where the complexity actually lives

By now the pattern from the exchange post should feel familiar. The core is small and stubbornly simple, and everything hard lives in the layers wrapped around it:

multiple currencies
snapshots and read performance
period close and backdating rules
retention and archival
regulatory reporting
migrating balances in from the old system

All real, all work, none of it changes the centre. The migration one deserves its own footnote of respect: the day you cut over from the balance column to the ledger, every opening balance arrives as, naturally, a transaction from an opening-balances account. Even the act of adopting the ledger is recorded in the ledger, and even that day the books sum to zero.

The actual lesson

Engineers keep reinventing this. Event sourcing, write-ahead logs, git, blockchains that spend a small nation's electricity to get properties Pacioli got with a quill: the pattern of "append facts, derive state" is rediscovered every decade by someone convinced it is new. It never is. It is double-entry bookkeeping, the original append-only data structure, running quietly underneath the world economy for five hundred years on the strength of two rules a Venetian merchant could hold in his head.

Write down what happened. Make it prove itself. Everything else is a query.

Further reading

  • Martin Fowler, Accounting Patterns: the software-shaped writeup of entries, transactions and posting rules, from someone who mapped the territory before most of us knew it was territory.
  • Modern Treasury, ledgers explained: a fintech patiently explaining to the industry that the balance column was never going to work, at scale, with lawyers involved.
  • TigerBeetle: a database that took double entry seriously enough to build an entire storage engine around it, which tells you how load-bearing the invariant really is.
  • Pacioli's Summa de Arithmetica (1494): the original documentation, still substantially correct after five centuries, a maintenance record the rest of us can only envy.

Human (you) in the loop

New writing, now and then

Occasional notes on platform engineering, building dependable software and that constant buzz word we doom scroll past on LinkedIn! No cadence promised.

Prefer a reader? Subscribe via RSS.

← All Articles

Jump to

32 articles