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
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) 1998The 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 (
BIGINTcents) with an explicit currency, orNUMERICwhen you need sub-cent precision (FX rates, per-unit fees). Neverfloat, and never Postgres’smoneytype, whose output depends on the server’slc_monetarysetting 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) / 100is 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
Numberis exact only up to 2^53 - 1, and Pydantic v2 already serialisesDecimalas a string in JSON mode.
Rounding is a business rule
round(2.675, 2) 2.67
round(2.5), round(3.5) 2 4
Decimal('2.665') default 2.66
HALF_UP 2.67Python’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:
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 partsnaive 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.0The invariant is easy to state and easy to test with Hypothesis, which finds the weight combinations a hand-written test misses:
@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)) == totalThe 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.
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:
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();=> BEGIN; INSERT INTO journals ...; INSERT INTO postings ... (+5000, -4825);
=> COMMIT;
ERROR: journal 42 does not balanceDEFERRABLE 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:
T1: read 100 T2: read 100
T1: 100 >= 80, post -80 T2: 100 >= 80, post -80
T1: COMMIT T2: COMMIT → balance -60Three 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 UPDATEon the account row before checking, when the rule is more than one comparison.SERIALIZABLEwith 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:
- Your ledger: what you believe happened.
- The PSP’s report (Stripe balance transactions, Adyen settlement reports): what the processor says it did, including its fees.
- The bank statement: what actually arrived, net of fees, days later (settlement is T+1 to T+N).
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 differingEvery 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.
Related
Interview angle 4
- “How do you store money?” - integer minor units with an explicit
currency, or
NUMERICfor sub-cent values; never float, never Postgresmoney. 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 asDecimalfrom 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.
- Follow-up:
- “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 conditionalUPDATE ... WHERE balance >= amount,FOR UPDATE, orSERIALIZABLEwith 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.