Skip to content

Ledgers and double entry

intermediateCommon atStripeSquareCoinbaseAnthropicRead firstMoney arithmeticHash mapsPractise in the Sandbox →

A ledger is an append-only list of journal entries. Each entry moves money between at least two accounts, and its legs sum to zero (debits = credits). A balance is never a number you overwrite: it is the sum of the legs that touched the account. Posting is O(legs); a balance is O(entries) unless you cache it, and the cache can always be rebuilt from the log.

A single-entry system is a list of balances: alice: 120. When a number looks wrong, you have nothing to check it against. Where did the money come from? Where did it go?

Double entry, used by accountants since the 15th century, adds one rule: money never appears or disappears, it only moves. Every entry says from where and to where. If $100 lands in cash, some other account records the other side: $100 of revenue, or $100 owed to a customer, or $100 less in receivables.

Two consequences make it powerful in software:

  • Every entry checks itself. If the legs don’t sum to zero, the entry is a bug and is rejected before it touches the books.
  • The whole ledger checks itself. Because every entry sums to zero, the sum over all accounts is always zero. This is the trial balance. If it isn’t zero, something wrote around the ledger.

Balances become a view over the history, like a database index over a table. You can rebuild them, audit them, and ask “what was the balance at 3pm yesterday?” by summing only older entries.

The words confuse everyone at first, so pin them down:

Account kind Example Grows with a… Shrinks with a…
Asset cash, receivables debit credit
Expense card fees, refunds paid debit credit
Liability money owed to sellers or customers credit debit
Equity owner investment credit debit
Revenue sales, fees earned credit debit

“Debit” just means left column, “credit” means right column. In code, the simplest encoding is a signed amount: debit is positive, credit is negative, and an entry is valid when its legs sum to 0. You flip the sign only when displaying a credit-normal account, so revenue shows as $20 rather than −$20.

Pick a preset or build your own entry, then post it. Try the unbalanced typo and see it rejected. Post a few entries, then Reverse one: the mistake stays in the journal, a mirror entry cancels it, and the balances on the right are recomputed from the journal every time.

Double-entry sandbox
post entries
NEW JOURNAL ENTRY
$
$
Dr $500.00 = Cr $500.00
JOURNAL (append-only)
Empty. Post an entry.
TRIAL BALANCE (derived from the journal)
accountdebitscreditsbalance
Cash
asset, grows on debit
$0.00$0.00$0.00
Receivable
asset, grows on debit
$0.00$0.00$0.00
Owed to sellers
liability, grows on credit
$0.00$0.00$0.00
Owner equity
equity, grows on credit
$0.00$0.00$0.00
Revenue
revenue, grows on credit
$0.00$0.00$0.00
Card fees
expense, grows on debit
$0.00$0.00$0.00
total$0.00$0.00Dr = Cr
Assets + Expenses = $0.00
Liabilities + Equity + Revenue = $0.00
The accounting equation holds, because every entry balanced on its own.

The marketplace preset is the shape most payment systems care about. One customer payment of $100 splits into four legs: cash actually received after the processor’s cut, the processor’s fee as an expense, the part you owe the seller as a liability, and your take as revenue. No single-entry balance can express that without losing information.

The problem says… Why a ledger fits
“balances”, “wallet”, “credits”, “account” with money moving between parties Each movement is an entry; balances fall out as sums.
“explain how we got this balance” / “audit” / “history” The journal is the explanation. Nothing is overwritten.
“balance as of time T” Sum entries with ts <= T. A mutable counter can’t answer this.
“refund”, “reversal”, “chargeback”, “correction” Post a new compensating entry instead of editing the old one.
several parties share one payment (fees, splits, taxes) Multi-leg entries keep every share explicit and still balanced.
“money must never be created or lost” The zero-sum check is a cheap invariant you can assert on every write.
  1. An entry has at least two legs. Money comes from somewhere and goes somewhere.
  2. Every leg is a non-zero integer amount in minor units (cents). No floats, ever. See money arithmetic.
  3. The legs sum to zero. Reject the whole entry otherwise: nothing partial is written.
  4. A balance is a fold over the legs that touched the account. Store a cached balance if you need speed, but treat it as derived data.
check_entry + balances.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Leg:
account: str
amount: int # integer cents; debit > 0, credit < 0
def check_entry(legs: list[Leg]) -> None:
"""Raise if a journal entry could break the books."""
if len(legs) < 2: # 1. at least two sides
raise ValueError("an entry needs at least two legs")
if any(not isinstance(l.amount, int) or l.amount == 0 for l in legs):
raise ValueError("legs are non-zero integer cents") # 2. no floats, no empty legs
if sum(l.amount for l in legs) != 0: # 3. debits == credits
raise ValueError("entry does not balance")
def balances(journal: list[list[Leg]]) -> dict[str, int]:
"""4. Balances are derived by folding every leg, never stored separately."""
out: dict[str, int] = {}
for entry in journal:
for leg in entry:
out[leg.account] = out.get(leg.account, 0) + leg.amount
return out
if __name__ == "__main__":
sale = [Leg("cash", 10_000), Leg("revenue", -9_000), Leg("tax_payable", -1_000)]
check_entry(sale)
try:
check_entry([Leg("cash", 500), Leg("revenue", -400)])
raise AssertionError("unbalanced entry accepted")
except ValueError:
pass
books = balances([sale, [Leg("tax_payable", 1_000), Leg("cash", -1_000)]])
assert books == {"cash": 9_000, "revenue": -9_000, "tax_payable": 0}
assert sum(books.values()) == 0 # trial balance: the whole ledger nets to zero
print("ok")

Correct mistakes by reversing, never by editing

Section titled “Correct mistakes by reversing, never by editing”
  1. Post a reversal: the same legs with signs flipped, linked to the original by id.
  2. Post the correct entry as a fresh entry.
  3. The journal now shows the mistake, the fix and who did both. Balances are right, and the audit trail is complete.
reverse.py
from dataclasses import dataclass, field
from itertools import count
_ids = count(1)
@dataclass(frozen=True)
class Entry:
memo: str
legs: tuple[tuple[str, int], ...] # (account, signed cents)
reverses: int | None = None
id: int = field(default_factory=lambda: next(_ids))
def reverse(e: Entry, memo: str) -> Entry:
"""Correct a mistake by posting the mirror image. The original stays in the log."""
return Entry(memo, tuple((acct, -amt) for acct, amt in e.legs), reverses=e.id)
def balance_of(journal: list[Entry], account: str) -> int:
return sum(amt for e in journal for acct, amt in e.legs if acct == account)
if __name__ == "__main__":
wrong = Entry("rent, typed 1200 instead of 2100", (("rent_expense", 120_000), ("cash", -120_000)))
journal = [wrong]
journal.append(reverse(wrong, "reverse typo")) # 1. undo with a new entry
journal.append(Entry("rent", (("rent_expense", 210_000), ("cash", -210_000)))) # 2. post the right one
assert balance_of(journal, "rent_expense") == 210_000
assert balance_of(journal, "cash") == -210_000
assert len(journal) == 3 and journal[1].reverses == wrong.id # 3. full audit trail kept
print("ok")
Operation Time Notes
Post an entry with k legs O(k) validate + append; update cached balances if you keep them
Balance by full scan O(total legs) always correct; fine for small data and for audits
Balance from a cache O(1) the cache is a hash map account → cents, updated in the same step as the append
Balance as of time T O(log n + legs before T) with a sorted log, or O(log n) with prefix sums per account entries appended in time order are already sorted, so binary search finds T
Rebuild all caches O(total legs) the recovery path: drop the cache, replay the journal

Real systems snapshot balances periodically (“balance at end of day = X”) so a rebuild or an as-of query only replays entries since the last snapshot. See snapshots.

  • Pending vs available. Money that has arrived but can’t be paid out yet is often a separate account (seller_pending) and a later entry moves it to seller_available. Holds are entries, not flags.
  • Multi-currency. Legs in one entry must balance per currency. Converting is itself an entry through an FX account: Dr cash_eur, Cr fx_clearing_eur, plus the matching USD side.
  • Balance constraints. “Never let this account go negative” is a check before posting, against the current balance. Some accounts (a merchant’s balance after a dispute) are allowed to go negative. Make the rule per account.
  • Per-account sequence numbers. Each account’s entries carry an increasing sequence, so readers can detect gaps and writers can do optimistic concurrency (“post only if the account is still at version 41”).
  • Ordered consumption. When a balance is made of separate “lots” (prepaid credit batches, inventory), you also need a rule for which lot a debit draws from: FIFO, earliest-expiry first, and so on. That’s extra structure on top of the ledger, usually a heap or sorted list of lots.
  • Storing the balance as the source of truth and logging “for reference”. The two drift the first time someone forgets to update one of them.
  • Floats for amounts. 0.1 + 0.2 != 0.3, so a correct entry can fail the zero-sum check, or worse, a broken one can pass with rounding noise.
  • Partial writes. Appending leg 1, crashing, never appending leg 2. Post an entry atomically: all legs or none (one DB transaction, or one record holding all legs).
  • Editing or deleting entries to fix a mistake. You lose the audit trail and every as-of query before the edit silently changes.
  • One-sided “adjustments” (“just add $5 to Alice”). Every adjustment needs a counter-account, even if it’s manual_adjustments. That account then shows how much was fudged.
  • Sign confusion. Pick one convention (debit positive) and convert only at the display edge.
  • Ask: Are amounts integers in minor units? Can balances go negative? Do I need history or only current balances? Is there more than one currency?
  • Say the invariant out loud: “Every entry sums to zero, so the sum of all balances is always zero. I’ll assert that in my tests.”
  • Explain why you derive balances: “I keep a cached map for O(1) reads, but it’s rebuildable from the journal, so a bug in the cache is recoverable.”
  • When a refund or correction comes up, reach for a new compensating entry immediately. Interviewers listen for “I never mutate past entries”.
  • Expected follow-ups: balance as of a past time, holds and release dates, multi-currency, idempotent posting under retries (see idempotency), concurrent posts to the same account (see concurrency).
Check yourself
pick one
A customer pays $50 in cash for a service. Which entry is correct (Dr = debit, Cr = credit)?
Check yourself
pick one
Last week an entry booked $1,200 rent instead of $2,100. What should the fix look like?
Check yourself
pick one
Your system stores a cached balance per account and the journal. A trial balance (sum of every account) comes out at +$3.00 instead of 0. What does that tell you?

Classic bookkeeping exercises, not gym questions:

  • Model a small shop’s month: owner invests, buys stock on credit, sells some for cash, pays the supplier. Print the trial balance after each entry.
  • Given a journal, compute each account’s balance as of any timestamp in O(log n) per query.
  • Split a $100 payment into seller share, platform fee and tax in one balanced multi-leg entry, with integer cents and no lost penny.
  • Detect the first entry that breaks the zero-sum invariant in a corrupted journal, and the account it most affected.