Ledgers and double entry
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.
The idea in plain words
Section titled “The idea in plain words”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.
Debits, credits and “normal balance”
Section titled “Debits, credits and “normal balance””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.
See it
Section titled “See it”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.
| account | debits | credits | balance |
|---|---|---|---|
| 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.00 | Dr = Cr |
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.
When to reach for it
Section titled “When to reach for it”| 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. |
How it works
Section titled “How it works”Validate the entry, then derive balances
Section titled “Validate the entry, then derive balances”- An entry has at least two legs. Money comes from somewhere and goes somewhere.
- Every leg is a non-zero integer amount in minor units (cents). No floats, ever. See money arithmetic.
- The legs sum to zero. Reject the whole entry otherwise: nothing partial is written.
- 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.
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")import assert from "node:assert/strict";
/** amount in integer cents; debit > 0, credit < 0 */interface Leg { account: string; amount: number }
/** Throw if a journal entry could break the books. */export function checkEntry(legs: Leg[]): void { if (legs.length < 2) throw new Error("an entry needs at least two legs"); // 1. two sides if (legs.some((l) => !Number.isSafeInteger(l.amount) || l.amount === 0)) throw new Error("legs are non-zero integer cents"); // 2. no floats, no empty legs if (legs.reduce((s, l) => s + l.amount, 0) !== 0) throw new Error("entry does not balance"); // 3. debits == credits}
/** 4. Balances are derived by folding every leg, never stored separately. */export function balances(journal: Leg[][]): Map<string, number> { const out = new Map<string, number>(); for (const entry of journal) for (const leg of entry) out.set(leg.account, (out.get(leg.account) ?? 0) + leg.amount); return out;}
const sale: Leg[] = [ { account: "cash", amount: 10_000 }, { account: "revenue", amount: -9_000 }, { account: "tax_payable", amount: -1_000 },];checkEntry(sale);assert.throws(() => checkEntry([{ account: "cash", amount: 500 }, { account: "revenue", amount: -400 }]));const books = balances([sale, [{ account: "tax_payable", amount: 1_000 }, { account: "cash", amount: -1_000 }]]);assert.deepEqual(Object.fromEntries(books), { cash: 9_000, revenue: -9_000, tax_payable: 0 });assert.equal([...books.values()].reduce((a, b) => a + b, 0), 0); // trial balance nets to zeroconsole.log("ok");var sale = new List<Leg> { new("cash", 10_000), new("revenue", -9_000), new("tax_payable", -1_000) };Ledger.CheckEntry(sale);bool rejected = false;try { Ledger.CheckEntry([new("cash", 500), new("revenue", -400)]); }catch (InvalidOperationException) { rejected = true; }if (!rejected) throw new Exception("unbalanced entry accepted");
var books = Ledger.Balances([sale, [new("tax_payable", 1_000), new("cash", -1_000)]]);if (books["cash"] != 9_000 || books["revenue"] != -9_000 || books["tax_payable"] != 0) throw new Exception("wrong balances");if (books.Values.Sum() != 0) throw new Exception("trial balance must net to zero");Console.WriteLine("ok");
/// Amount in integer cents (long): debit > 0, credit < 0.record Leg(string Account, long Amount);
static class Ledger{ public static void CheckEntry(IReadOnlyList<Leg> legs) { if (legs.Count < 2) throw new InvalidOperationException("an entry needs at least two legs"); // 1 if (legs.Any(l => l.Amount == 0)) throw new InvalidOperationException("no empty legs"); // 2 (long: no floats) if (legs.Sum(l => l.Amount) != 0) throw new InvalidOperationException("entry does not balance"); // 3 }
// 4. Balances are derived by folding every leg, never stored separately. public static Dictionary<string, long> Balances(IEnumerable<IReadOnlyList<Leg>> journal) { var outp = new Dictionary<string, long>(); foreach (var entry in journal) foreach (var leg in entry) outp[leg.Account] = outp.GetValueOrDefault(leg.Account) + leg.Amount; return outp; }}Correct mistakes by reversing, never by editing
Section titled “Correct mistakes by reversing, never by editing”- Post a reversal: the same legs with signs flipped, linked to the original by id.
- Post the correct entry as a fresh entry.
- The journal now shows the mistake, the fix and who did both. Balances are right, and the audit trail is complete.
from dataclasses import dataclass, fieldfrom 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")import assert from "node:assert/strict";
type Legs = readonly (readonly [account: string, cents: number])[];interface Entry { readonly id: number; readonly memo: string; readonly legs: Legs; readonly reverses?: number }
let nextId = 1;const entry = (memo: string, legs: Legs, reverses?: number): Entry => ({ id: nextId++, memo, legs, reverses });
/** Correct a mistake by posting the mirror image. The original stays in the log. */export function reverse(e: Entry, memo: string): Entry { return entry(memo, e.legs.map(([acct, amt]) => [acct, -amt] as const), e.id);}
export function balanceOf(journal: readonly Entry[], account: string): number { let total = 0; for (const e of journal) for (const [acct, amt] of e.legs) if (acct === account) total += amt; return total;}
const wrong = entry("rent, typed 1200 instead of 2100", [["rent_expense", 120_000], ["cash", -120_000]]);const journal: Entry[] = [wrong];journal.push(reverse(wrong, "reverse typo")); // 1. undo with a new entryjournal.push(entry("rent", [["rent_expense", 210_000], ["cash", -210_000]])); // 2. post the right oneassert.equal(balanceOf(journal, "rent_expense"), 210_000);assert.equal(balanceOf(journal, "cash"), -210_000);assert.equal(journal[1].reverses, wrong.id); // 3. full audit trail keptconsole.log("ok");var wrong = Journal.New("rent, typed 1200 instead of 2100", [("rent_expense", 120_000), ("cash", -120_000)]);var journal = new List<Entry> { wrong };journal.Add(Journal.Reverse(wrong, "reverse typo")); // 1. undo with a new entryjournal.Add(Journal.New("rent", [("rent_expense", 210_000), ("cash", -210_000)])); // 2. post the right one
if (Journal.BalanceOf(journal, "rent_expense") != 210_000) throw new Exception("rent");if (Journal.BalanceOf(journal, "cash") != -210_000) throw new Exception("cash");if (journal.Count != 3 || journal[1].Reverses != wrong.Id) throw new Exception("audit trail"); // 3Console.WriteLine("ok");
record Entry(int Id, string Memo, IReadOnlyList<(string Account, long Cents)> Legs, int? Reverses = null);
static class Journal{ static int _next = 1; public static Entry New(string memo, IReadOnlyList<(string, long)> legs, int? reverses = null) => new(_next++, memo, legs, reverses);
// Correct a mistake by posting the mirror image. The original stays in the log. public static Entry Reverse(Entry e, string memo) => New(memo, e.Legs.Select(l => (l.Account, -l.Cents)).ToList(), e.Id);
public static long BalanceOf(IEnumerable<Entry> journal, string account) => journal.SelectMany(e => e.Legs).Where(l => l.Account == account).Sum(l => l.Cents);}What it costs
Section titled “What it costs”| 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.
Variations you’ll meet
Section titled “Variations you’ll meet”- 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 toseller_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, Crfx_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.
Pitfalls
Section titled “Pitfalls”- 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.
In the interview
Section titled “In the interview”- 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
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”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.
Go deeper
Section titled “Go deeper”- Martin Fowler, Accounting Patterns (Account, Accounting Entry, Posting Rule, Reversal Adjustment): the software-design view of double entry.
- Modern Treasury, Accounting for Developers, parts I–III: debits, credits and ledgers explained for engineers.
- Square Engineering, Books: an immutable double-entry accounting database service: a production ledger with immutable entries.
- Stripe Engineering, Ledger: Stripe’s system for tracking and validating money movement: double entry at scale, with the zero-sum checks used as data-quality alarms.