Backend / Quant & fintech / 09_double_entry_ledger.md

The double-entry ledger

Updated 4 interview angles 9 min read source
On this page10
  1. Money is an integer, in minor units
  2. Splitting money without losing a cent
  3. The model: journals and postings
  4. Two withdrawals at once
  5. Immutable, and provably so
  6. Reconciliation: proving it against the outside world
  7. Where it breaks in production
  8. The trade not taken
  9. Related
  10. Interview angle

The double-entry ledger

Article.

How do you build a ledger that can never create, lose or double-count a cent? Every payments, wallet, marketplace or trading backend has one, and fintech interviews probe it from four directions: how money is represented, how the books are kept balanced by construction, what happens when two withdrawals race, and how you prove your numbers match the bank’s.

The answer is a 500-year-old idea, double entry, enforced by the database rather than by code review.

Idempotent payment submission is in Worked Design — Idempotent Payment Flow; this note is what happens after the money moves.

Money is an integer, in minor units

text
total += 0.1, ten times   0.9999999999999999
0.1 + 0.2                 0.30000000000000004
sum([0.1] * 10)           1.0 (3.12+: compensated sum)
int(19.99 * 100)          1998

The last line is the production bug: converting a price in dollars to cents with a float loses a cent, silently. (The classic sum([0.1] * 10) demo no longer shows the problem: since Python 3.12 sum() of floats uses compensated summation. A running += still drifts.)

  • Store integer minor units (BIGINT cents) with an explicit currency, or NUMERIC when you need sub-cent precision (FX rates, per-unit fees). Never float, and never Postgres’s money type, whose output depends on the server’s lc_monetary setting and which the PostgreSQL wiki lists under “Don’t Do This”.
  • Minor units differ per currency (ISO 4217): JPY has 0 decimals, USD and EUR 2, KWD and BHD 3. Decimal(cents) / 100 is wrong for two of those three. Keep the exponent in a currency table.
  • At the JSON boundary, send amounts as strings or integers: a JavaScript Number is exact only up to 2^53 - 1, and Pydantic v2 already serialises Decimal as a string in JSON mode.

Rounding is a business rule

text
round(2.675, 2)           2.67
round(2.5), round(3.5)    2 4
Decimal('2.665') default  2.66
            HALF_UP       2.67

Python’s round() and Decimal’s default context both use banker’s rounding (half to even), and 2.675 is not exactly representable as a float to begin with. Invoices, tax and interest usually specify half-up (or a regulator’s rule). Pass rounding= explicitly every time you quantize, and round once, at a defined step, not at every intermediate.

Splitting money without losing a cent

Dividing 100.00 three ways gives 33.33 three times, and a cent disappears. Largest-remainder allocation hands the leftover units to the parts with the largest fractional remainders:

python
def allocate(total_minor: int, weights: list[int]) -> list[int]:
    s = sum(weights)
    raw = [total_minor * w for w in weights]
    parts = [r // s for r in raw]
    left = total_minor - sum(parts)
    by_remainder = sorted(range(len(weights)), key=lambda i: raw[i] % s, reverse=True)
    for i in by_remainder[:left]:
        parts[i] += 1
    return parts
text
naive 100.00 / 3          [33.33, 33.33, 33.33] sum 99.99
largest remainder         ['33.34', '33.33', '33.33'] sum 100.0
10.00 by 70/20/10/3       ['6.80', '1.94', '0.97', '0.29'] sum 10.0

The invariant is easy to state and easy to test with Hypothesis, which finds the weight combinations a hand-written test misses:

python
@given(st.integers(0, 10**12), st.lists(st.integers(1, 1000), min_size=1, max_size=20))
def test_allocation_conserves_money(total, weights):
    assert sum(allocate(total, weights)) == total

The model: journals and postings

Every movement of money is a journal (one business event: a payment, a fee, a refund) made of postings to accounts. The postings of a journal sum to zero per currency: money is never created or destroyed, only moved.

sql
CREATE TABLE accounts (
  id        bigint PRIMARY KEY,
  name      text   NOT NULL,          -- "user:42:wallet", "platform:fees"
  currency  char(3) NOT NULL,
  kind      text   NOT NULL           -- asset | liability | revenue | expense
);
CREATE TABLE journals (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  source      text   NOT NULL,        -- "stripe", "payout-job"
  external_id text   NOT NULL,        -- the event id from that source
  created_at  timestamptz NOT NULL DEFAULT clock_timestamp(),
  UNIQUE (source, external_id)        -- the same event cannot post twice
);
CREATE TABLE postings (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  journal_id  bigint NOT NULL REFERENCES journals,
  account_id  bigint NOT NULL REFERENCES accounts,
  currency    char(3) NOT NULL,
  amount      bigint NOT NULL         -- minor units; debit +, credit -
);

A card payment of 50.00 with a 1.75 fee is one journal with three postings:

Account Amount
psp:receivable +5000 (the PSP owes us)
user:42:wallet -4825
platform:fees -175

Balanced by construction

The invariant is enforced by the database at commit, not by application code that a future change can skip:

sql
CREATE FUNCTION check_journal_balanced() RETURNS trigger AS $$
BEGIN
  IF EXISTS (SELECT 1 FROM postings WHERE journal_id = NEW.journal_id
             GROUP BY currency HAVING sum(amount) <> 0) THEN
    RAISE EXCEPTION 'journal % does not balance', NEW.journal_id;
  END IF;
  RETURN NULL;
END $$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER postings_balanced
  AFTER INSERT ON postings
  DEFERRABLE INITIALLY DEFERRED            -- checked at COMMIT, after all rows
  FOR EACH ROW EXECUTE FUNCTION check_journal_balanced();
text
=> BEGIN; INSERT INTO journals ...; INSERT INTO postings ... (+5000, -4825);
=> COMMIT;
ERROR:  journal 42 does not balance

DEFERRABLE INITIALLY DEFERRED matters: the postings of one journal are inserted one at a time, and the sum is only zero once all of them are in.

Balances are derived: SELECT sum(amount) FROM postings WHERE account_id = $1. At volume, keep a cached balance column on the account, updated in the same transaction as the postings, and verify it against the sum in a nightly job; the postings stay the source of truth.

Two withdrawals at once

Under READ COMMITTED (Postgres’s default), check-then-act overdraws:

text
T1: read 100               T2: read 100
T1: 100 >= 80, post -80    T2: 100 >= 80, post -80
T1: COMMIT                 T2: COMMIT  → balance -60

Three fixes, in order of preference:

  • A conditional update that checks and changes in one statement: UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1, then treat zero rows as “insufficient funds”. The row lock makes the second writer re-check against the new balance.
  • SELECT ... FOR UPDATE on the account row before checking, when the rule is more than one comparison.
  • SERIALIZABLE with a retry loop on serialization failures, when the rule spans many rows.

More anomalies across databases: Isolation anomalies across databases.

Hot accounts

Every payment also posts to platform:fees, so that one row’s lock serialises the whole system: throughput is capped by how fast one row can be updated and committed. Fixes: split the hot account into N sub-accounts chosen at random and summed when read, or post to it in batches asynchronously (the fee account need not be current to the millisecond). Purpose-built ledgers exist for exactly this (below).

Immutable, and provably so

A ledger is append-only. Corrections are reversing journals (post the opposite amounts, then the right ones), never UPDATE or DELETE:

  • REVOKE UPDATE, DELETE ON postings, journals FROM app_role; and a trigger that raises on either, for anyone else.
  • Hash-chain the journals (each row stores SHA-256 of the previous row’s hash plus its own content) so a direct edit by a DBA is detectable; a verification job recomputes the chain.
  • Order by a sequence, not by now(), which is the transaction’s start time and repeats within a transaction; clock_timestamp() gives wall time for display.
  • Retention: periodic exports to WORM storage (S3 Object Lock in compliance mode) satisfy auditors that history cannot be rewritten.

Idempotent posting

Postings arrive from webhooks, jobs and retries, all at least once. The UNIQUE (source, external_id) on journals turns a replayed Stripe event or a re-run payout job into a constraint violation you catch and ignore, inside the same transaction as the postings. Anything that must happen after posting (emails, downstream services) goes through the outbox (The transactional outbox).

Reconciliation: proving it against the outside world

Your ledger can balance perfectly and still be wrong about reality. Reconciliation compares three records daily:

  1. Your ledger: what you believe happened.
  2. The PSP’s report (Stripe balance transactions, Adyen settlement reports): what the processor says it did, including its fees.
  3. The bank statement: what actually arrived, net of fees, days later (settlement is T+1 to T+N).
sql
SELECT coalesce(l.ref, p.ref) AS ref, l.amount AS ledger, p.amount AS psp
  FROM ledger_payments l
  FULL OUTER JOIN psp_report p ON p.ref = l.ref AND p.currency = l.currency
 WHERE l.amount IS DISTINCT FROM p.amount;      -- missing on either side, or differing

Every mismatch is a break: it goes to a queue with an owner and an ageing target, and the day is “closed” only when breaks are explained. Payouts arrive as one net amount for many payments, so matching the bank line needs the PSP’s payout breakdown, not individual payments.

Where it breaks in production

  • A cent lost in conversion: int(price * 100) on a float, or dividing minor units by 100 for a 0- or 3-decimal currency.
  • Rounding disputes: banker’s rounding where the contract says half-up.
  • Overdrafts under load: check-then-act on the balance without a lock.
  • A throughput ceiling: every transaction updating one fee or settlement account row.
  • Double posting: a webhook replayed after the idempotency record was pruned, with no unique key on the journal.
  • Silent drift: a cached balance updated outside the posting transaction, never checked against the postings.
  • A month-end surprise: reconciliation done monthly, so a break is weeks old and its context gone.

The trade not taken

A purpose-built or hosted ledger. TigerBeetle is a database designed for double-entry accounting (debit/credit transfers, balance limits, idempotency built in) and handles hot accounts far better than row locks in Postgres; hosted ledger APIs from payment-infrastructure vendors remove the schema entirely. The cost is another system of record to operate or depend on, and moving the invariants out of the database your team understands.

Postgres with the schema above serves most products up to thousands of transactions per second; reach for a dedicated ledger when hot accounts or throughput, measured, require it.

Interview angle 4

  • “How do you store money?” - integer minor units with an explicit currency, or NUMERIC for sub-cent values; never float, never Postgres money. The number of decimals comes from the currency.
    • Follow-up: int(19.99 * 100) gives what? 1998: the float is slightly below 19.99. Parse amounts as Decimal from strings.
    • Follow-up: how do you split 100.00 three ways? Largest remainder: 33.34, 33.33, 33.33, with a property test that the parts sum to the whole.
  • “How do you guarantee the books balance?” - every journal’s postings sum to zero per currency, enforced by a deferred constraint trigger at commit; balances are derived from postings.
    • Follow-up: a refund was posted with the wrong amount. How do you fix it? A reversing journal and a correct one; postings are never updated or deleted.
  • “Two withdrawals of 80 hit an account holding 100 at the same time.” - check-then-act overdraws under READ COMMITTED; use a conditional UPDATE ... WHERE balance >= amount, FOR UPDATE, or SERIALIZABLE with retries.
    • Follow-up: now every payment waits on the platform fee account. Split it into sub-accounts summed on read, or post fees in batches.
  • “How do you know your ledger matches reality?” - daily three-way reconciliation of ledger, PSP report and bank statement, with breaks in a queue that has owners and an ageing target.