25 min

Double entry: every move has two legs

Double-entry bookkeeping records every movement as balanced debit and credit legs, turning 'the books balance' into an invariant a program can assert.

Where you are. Module 0 left you holding the two-tier map and a traced payment whose conservation assertions you kept green yourself. Inside this module, lesson 1 scaffolded the miniledger package the milestone assembles, and lesson 2 made the record executable: money as a list, a payment as paired edits, refusal before either edit touches a balance. But that pairing discipline lives in pay(), the one function you wrote carefully. This lesson moves the discipline into the ledger itself, where no future caller can forget it, and pays the entry fee for doing so: every account must first declare what kind of line it is.

The fee that vanishes

Picture your lesson 2 ledger six months on. It has grown refunds, interest and a dozen call sites, and this morning a colleague ships the monthly account fee: look up alice’s balance, subtract 5, return. Three lines, tests green. And every run quietly loses money, because the 5 leaves alice’s row and arrives nowhere. At the moment of writing, nothing objects; the list will happily hold a half-recorded move, and the conservation assert you wrote in lesson 2 lives at the end of a run, so the alarm - if anyone still runs it - fires hours and thousands of writes away from the three lines that caused it.

Engineers have a name for this shape of failure: a torn write, an operation that was meant to be all-or-nothing, caught mid-tear and made durable. Lesson 2’s answer was a careful function. But careful functions do not compound; every new feature is a fresh chance to skip the second edit. So sharpen the question: what single rule, checked at the instant of writing, would make the torn write impossible even to write down?

The rule is not new. Luca Pacioli printed it in 1494, Venetian merchants kept their books by it before that, and your bank’s core system will enforce it tonight: never record a movement. Record the movement’s legs, and refuse them unless they balance. “The books balance” stops being a virtue an auditor checks for and becomes an invariant a program asserts.

The idea in one paragraph

Double entry records every move as one posting made of two or more legs, and demands that the legs balance: measured across the two sides of the books, their changes cancel to nothing. Making “balance” checkable is what the types are for. Every account declares a kind - asset for what the ledger’s owner holds, liability for what it owes, equity for what is the owner’s own - and a posting balances when the change in assets equals the change in liabilities plus the change in equity. A ledger that enforces this at the point of writing does not detect half a move later; it cannot store one at all. The invalid state is not caught. It is unrepresentable.

Three kinds of line

Lesson 2’s list treated every row as the same kind of thing: a name and a number. A real ledger cannot, because of the fact module 0 kept circling - your deposit is an asset to you and, simultaneously, a liability of your bank - which means the rows on one ledger face different directions. Sit in the bank’s chair and sort its own rows:

  • asset - what the bank holds. Its reserves, the bank’s own balance at the central bank, are the first asset you have met; lesson 4 adds loans beside them.
  • liability - what the bank owes. alice’s deposit is the goldsmith’s IOU from module 0, now a typed row: the bank’s promise to alice, currently sized 500.
  • equity - what is left for the bank’s owners once everything owed is subtracted from everything held. Today it is a declared-but-quiet third kind; lesson 4 gives it its real job.

The traditional vocabulary for a leg’s two directions is debit and credit, one column each per account, with per-kind rules about which column grows which balance. The miniledger keeps the idea and drops the columns: a leg is a signed amount, and a positive amount increases the account’s balance, whatever its kind. The balance rule below does the work the column discipline used to do, without the mnemonics.

The rule is about changes, not totals

The obvious rule - the one lesson 2 actually enforced - is that a posting’s changes sum to zero. It worked because alice and bob were rows of the same kind, two deposits on one bank’s ledger. Watch it fail on the oldest move a bank makes: alice deposits 500. How the 500 arrives - over a counter, across a wire from another bank - is lesson 6’s and lesson 8’s business; today only the bank’s own two rows matter. Its reserves rise 500, and its deposit owed to alice rises 500. Raw sum of the changes: plus 1,000. The naive rule refuses the posting. Yet the books are perfectly square: the bank holds 500 more and owes 500 more.

The fix is to stop summing across kinds and start comparing them:

Δassets=Δliabilities+Δequity\Delta\,\text{assets} = \Delta\,\text{liabilities} + \Delta\,\text{equity}

In words: whatever a posting does to what the bank holds, it must do the same, in total, to what the bank owes plus what belongs to the bank itself. This is the change form of the identity whose standing form lesson 4 teaches, assets equal liabilities plus equity, and shows that a bank simply is that equation.

postingdAssetsdLiabilities + dEquityverdict
alice deposits 500: reserves +500, alice +500+500+500balances
alice pays bob 100: alice -100, bob +10000balances
a lone leg: alice -50, nothing else0-50refused

Lesson 2’s sum-to-zero rule survives inside this one: when every leg shares a single kind, as in the middle row, the identity collapses to exactly it. The top row is why the general rule must compare the sides instead of summing them.

Accountants draw one account as a T: the name across the top, increases and decreases listed beneath. Here is the deposit as two T-accounts, the posting’s two legs picked out in gold:

reserves asset · what the bank holds increase decrease +500 alice's deposit liability · what the bank owes alice increase decrease +500 one posting: "alice deposits 500"; two legs, applied together or not at all dAssets +500 == dLiabilities +500 + dEquity 0; balanced, so the ledger accepts it
One posting, two legs: alice's deposit lands as +500 in the reserves T-account and +500 in the deposit T-account, and the ledger accepts the pair only because the changes balance across the change rule

Wider than the screen; scroll it sideways.

A ledger that can say no

The exercise’s Stage3Ledger is small enough to hold in your head: accounts maps a name to a [kind, balance] pair, open(name, kind) adds a row, and post(entries, memo) is where this lesson lives. Its contract, in order:

  1. Tally. Walk the legs and add each amount into a bucket per kind: one number each for dAssets, dLiabilities, dEquity.
  2. Test. Check dAssets == dLiabilities + dEquity.
  3. Refuse. If the test fails, raise ValueError naming the memo - and raise before any balance has been touched.
  4. Apply. Only then write every leg.

The order is the whole design. Because validation precedes application, the torn state never exists even transiently, and there is no undo machinery to get wrong: the only states the ledger can occupy are before the posting and after all of it. Driving it looks like this:

led = Stage3Ledger()
led.open("reserves", ASSET)        # what the bank holds
led.open("alice", LIABILITY)       # what the bank owes
led.post([("reserves", 500), ("alice", 500)], "alice deposits 500")
led.post([("alice", -50)], "half a payment")   # ValueError; nothing written

Nor is this a toy convention you will outgrow. The finished library this module assembles enforces the same contract: Ledger.post(entries: list[tuple[str, int]], memo: str = "") in miniledger/ledger.py tallies per kind and refuses with a message shaped like dAssets 0 != dLiabilities -50 + dEquity 0. After applying, it re-asserts the whole sheet anyway - in its own words, “belt and braces is what a ledger is for” - and lesson 4 builds that second check.

Check yourself

1. alice’s deposit posts as reserves +500 and alice +500, so the raw changes sum to +1,000. Lesson 2’s payment posted alice -100 and bob +100, summing to zero. State the one rule that accepts both.

Split the changes by kind and compare the sides: dAssets must equal dLiabilities plus dEquity. The deposit balances as +500 against +500 plus 0; the payment as 0 against (-100 + 100) plus 0. Lesson 2’s sum-to-zero was the special case of the rule in which every leg happens to share one kind, so the two sides collapse into a single sum.

2. post validates the identity before touching any balance. What does check-then-apply buy that apply-then-check-then-undo would not?

With check-then-apply, the torn state never exists, even for a microsecond, and there is no undo code to get wrong: a crash between “apply” and “undo” would be precisely the durable half-move the rule exists to prevent. The ledger’s reachable states are exactly two, before the posting and after all of it, which is what atomic means.

3. A posting moves 1,000 from alice to bob; the invoice said 100. Does the ledger refuse it?

No. dAssets changes by 0 and dLiabilities by 0, so the posting balances and applies. Double entry checks a move’s shape, not its meaning, and a wrong but balanced posting sails straight through. That is why the checks stack - lesson 4’s sheet-level assertion, lesson 7’s refusal logic - and why balance is the floor of correctness, never the ceiling.

4. Why does this lesson say half a move is “unrepresentable” rather than “detected”?

Detection implies the bad state existed and something later found it, the way lesson 2’s end-of-run assert found the vanished fee hours late. Here the refusal happens before any write, so no stored state ever exists for a checker to find: there is no code path by which an unbalanced posting reaches the ledger at all. It is the difference between a midnight scan and a constraint.

Do this

Fifteen minutes, standard library only. Open code/double_entry.py: Stage3Ledger arrives with open() written and one TODO(you) inside post(). Implement the four-step contract: tally the posting’s change per kind, test dAssets == dLiabilities + dEquity, raise ValueError naming the memo before any leg applies, then apply atomically.

python3 code/double_entry.py

Run unmodified, the starter stops at NotImplementedError. Completed, the harness posts the 500 deposit - both legs up, and your rule must accept it - then attempts [("alice", -50)] under the memo “half a payment” and requires your refusal, printing rejected: unbalanced posting: half a payment before ending with the line

balanced postings enforced; the ledger cannot record half a move

If the run dies on the deposit instead, your rule is summing changes to zero across kinds; rework the table’s top row by hand and compare sides, not totals.

Then put the hook to bed. After the harness passes, append the fee done properly:

led.open("bank equity", EQUITY)
led.post([("alice", -5), ("bank equity", 5)], "monthly fee")

dAssets 0 against dLiabilities -5 plus dEquity +5: the bank owes alice five less, and the five is now the owners’ own. Notice that lesson 2’s flat list could not have recorded this fee honestly at all - no row of the right kind existed to receive it. On the typed ledger, the vanishing fee has exactly one representable form, and it is the honest one: the form that names who gained. The completed version is in solutions/double_entry.py.

What you can now do. You can type a ledger’s rows as assets, liabilities and equity, enforce dAssets equals dLiabilities plus dEquity at the moment of posting, and say precisely why that makes half a move unrecordable rather than merely detectable: refusal precedes writing, so the torn state has no representation. You can also say what the rule does not promise - a balanced posting can still be wrong - and name the checks that stack on top of it. The next lesson takes the same three kinds and sums them instead of differencing them: assets equal liabilities plus equity as the standing shape of a bank, with your deposit on the liability side, exactly where module 0 said it lived.

What you can now do

You can enforce balanced legs on a typed ledger and explain why the rule makes half a move unrecordable.