Double-entry ledger vs balance columns: an interactive payments simulator
Payment systems do not store money as a balance column that goes up and down. They record every movement as a journal entry with at least two lines, a debit from one account and an equal credit to another, so the lines of every entry sum to zero. Balances are derived by adding lines up. This is double-entry bookkeeping, and in software it turns lost and duplicated money from silent corruption into something that cannot happen or is caught at once.
The playground records four transfers both ways. Switch on a retried request and a crash in the middle of a transfer, and compare the balances.
Four transfers: Alice tops up her wallet ($100); Alice pays Bob back for dinner ($40); Bob buys a book ($25); the shop refunds Alice a fee ($10). With balance columns, the retry moves Alice’s $40 twice. 2 of 4 balances end up wrong, yet the accounts still add up to zero, so a balance check alone would not notice. In the ledger every transfer is one journal entry whose lines sum to zero, written in a single transaction under an idempotency key: the duplicate request is recognised and ignored. Every balance is right and the books balance.
- t1moved $100 from bank to alice
- t2moved $40 from alice to bob
- t2the retry moved another $40
- t3moved $25 from bob to shop
- t4moved $10 from shop to alice
| Account | Balance | Should be |
|---|---|---|
| bank (money in) | -$100 | -$100 |
| alice | $30 | $70 |
| bob | $55 | $15 |
| shop | $15 | $15 |
| All accounts together | $0 | $0 |
- t1journal entry t1: debit bank $100, credit alice $100
- t2journal entry t2: debit alice $40, credit bob $40
- t2the retry found idempotency key t2 already recorded and returned the first result
- t3journal entry t3: debit bob $25, credit shop $25
- t4journal entry t4: debit shop $10, credit alice $10
| Account | Balance | Should be |
|---|---|---|
| bank (money in) | -$100 | -$100 |
| alice | $70 | $70 |
| bob | $15 | $15 |
| shop | $15 | $15 |
| All accounts together | $0 | $0 |
What goes wrong with balance columns
A transfer done as two updates, subtract here, add there, has two failure modes. If the client times out and retries a request that actually succeeded, the money moves twice: Alice pays Bob $80 instead of $40. The accounts still add up, so a total check does not notice; the error is in who has the money. If the server crashes between the two updates, $25 leaves Bob and never reaches the shop: the accounts now add up to −$25, and nothing records where it went. Worse, the shop’s later refund drives its balance negative.
How the ledger prevents it
- Atomic entries. Both lines of an entry are inserted in one transaction, so a crash rolls back the whole transfer and the client can safely retry it.
- Idempotency keys. Each entry carries the request’s key under a unique constraint, so a retry finds the entry already posted and returns the first result. See idempotency keys.
- An invariant you can check. The sum of all lines is always zero. A reconciliation job that sums lines per entry, per account and against the bank’s statements catches any bug that slips through.
- History for free. A balance is explained by its lines. Corrections are new entries that reverse old ones, never edits, so the record is append-only and auditable.
At scale, summing lines for every read is too slow, so systems also keep a balance per account, updated in the same transaction as the lines, often with a version check or row lock to stop two transfers from overdrawing an account at once. The lines remain the source of truth.
A ledger schema in SQL
CREATE TABLE journal_entries (
id bigserial PRIMARY KEY,
idempotency_key text UNIQUE NOT NULL, -- a retried request cannot post twice
memo text,
created_at timestamptz DEFAULT now()
);
CREATE TABLE ledger_lines (
entry_id bigint REFERENCES journal_entries,
account_id bigint NOT NULL,
amount bigint NOT NULL -- cents; debits negative, credits positive
);
BEGIN;
INSERT INTO journal_entries (idempotency_key, memo) VALUES ('t2', 'dinner') RETURNING id;
INSERT INTO ledger_lines VALUES (:id, :alice, -4000), (:id, :bob, 4000);
COMMIT; -- both lines or neither; every entry's lines sum to zero
-- a balance is a sum, so it can always be explained line by line
SELECT sum(amount) FROM ledger_lines WHERE account_id = :alice;In a system design interview
For anything that moves money, lead with the ledger: “Every payment posts a journal entry with balanced debit and credit lines in one transaction, keyed by the client’s idempotency key. Balances are cached per account and updated in the same transaction with a row lock, rejecting overdrafts. Calls to the card network happen outside the transaction through an outbox, and a nightly job reconciles our ledger against the processor’s settlement files.” Expect the follow-ups: hot accounts, multi-currency entries, and what happens when the external charge succeeds but our write fails.
Read the guide to payments and ledgers or digital wallets, see the transactional outbox and deadlocks, or watch a strong candidate design a payment system.
Questions people ask
What is a double-entry ledger?
A record of money movements where every transaction is a journal entry with at least two lines, debits and credits, that sum to zero. Account balances are derived by summing lines, so money can only move between accounts, never appear or disappear.
Why do payment systems use double-entry bookkeeping?
Because it makes errors detectable and history explainable. The total of all lines must be zero, so lost or created money shows up in reconciliation, and every balance can be traced to the entries that produced it. Updating balance columns directly loses both properties.
How do you prevent a payment from being applied twice?
Give each request an idempotency key and store it with the journal entry under a unique constraint. A retried request finds the entry already posted and returns the original result instead of moving the money again.
What happens if a server crashes in the middle of a transfer?
With a ledger, the debit and credit lines are written in one database transaction, so a crash rolls back both and the transfer can be retried. With two separate balance updates, a crash between them removes money from one account without adding it to the other.
How do you correct a mistake in a ledger?
By posting a new reversing entry, never by editing or deleting old ones. The ledger stays append-only, so auditors and engineers can see both the mistake and its correction.
How do you get an account balance quickly from a ledger?
Keep a cached balance per account, updated in the same transaction as the ledger lines and protected by a row lock or version check to prevent overdrafts. Periodic jobs verify the cached balance against the sum of lines, which remain the source of truth.
What is reconciliation in payments?
Comparing your ledger against independent records, such as the payment processor’s settlement reports and bank statements, and checking internal invariants like every entry summing to zero. It catches bugs, missed webhooks and discrepancies with external systems.