Double-Entry Ledgers, Explained for Developers
A balance column drifts the moment two writers touch it. Store the entries, derive the balance, lock the row inside the transaction, and correct mistakes with reversals instead of edits.

There is a column called balance in the accounts table. It is the number the product shows, the number support quotes on the phone, and the number the monthly report adds up. In most systems we have read, it is also wrong. Not wrong by much, and not wrong every day: wrong after the retry that ran twice, after two refunds that landed in the same second, after the import that wrote entries but skipped the column.
We have repaired this shape of bug more than once, and the repair was never a better UPDATE. The repair was to stop treating the number as the fact and start treating the movements as the fact. Bookkeeping has worked this way for centuries, and it maps onto a relational database almost line for line once you drop the accounting vocabulary.
A stored balance is a cache with no invalidation rule
A balance column is denormalised data. That is not a sin; every fast system caches something. A cache needs two things to be safe: a rule for when it may be wrong, and a way to rebuild it from the source. A balance column usually has neither, so it drifts, and nobody can say when.
The four ways it drifts are always the same.
- The write is not atomic. Insert the entry, then update the balance. If the process dies between the two statements, or the second one times out, the ledger and the number disagree. Wrapping both in a transaction fixes this case and only this case.
- Two writers read the same value. Two transfers each read 1,000 lei, each subtract 100, each write 900. Two hundred lei left the account and the column recorded one hundred.
- The past changes shape. A migration, a backfill or a corrected report recomputes history, and the column is not part of the recomputation.
- A person edits it. Support fixes a complaint by setting the number to what the customer expects. The evidence of what happened is now gone, which is exactly what you need when the same customer calls again.
There is a diagnostic question that separates the two designs. Ask why an account holds 4,213.17 lei. If the answer is "because the column says 4,213.17", you have a number. If the answer is a list of dated movements that sums to it, you have a ledger, and you can answer the next question too: which movement is wrong.
Debits and credits are a sign convention
You do not need to learn accounting to implement this. You need one table and one rule.
An entry has an account, a transaction it belongs to, a signed amount in minor units, a currency, and a timestamp. That is the whole schema. Debit and credit are the names accountants give to the positive and negative signs. A debit increases an asset or an expense account; a credit decreases it. For liabilities, equity and revenue the signs flip. If that sentence is the only accounting you ever learn, it is enough.
Take an invoice for 5,000 lei sent to a client. Two entries, one transaction:
- receivable, +500,000 bani: the client now owes you money, and that is an asset;
- revenue, -500,000 bani: you earned it.
When the money arrives:
- bank, +500,000 bani;
- receivable, -500,000 bani: the client no longer owes it.
The balance of any account is SUM(amount) over its entries. Nothing else. Amounts are integers in the smallest unit, because 0.1 + 0.2 in binary floating point is not 0.3, and a ledger that is wrong by one ban is a ledger nobody trusts. Keep the currency on every entry, and if a transaction converts between currencies, post the conversion as its own pair of entries with the rate you actually got, not the one from the news.
The zero is an invariant, not a style
Every transaction must sum to zero across all its entries. That is the rule that makes the design self-checking. It means a single insert cannot invent money: for anything to appear in one account, something must disappear from another or arrive from outside. A row without a counterpart is a bug, and the database can refuse it at commit with a deferred constraint, before anyone sees a wrong number on a screen.
Money does arrive from outside, of course. A client pays into your bank account and there is no internal account to credit. The counterpart is a suspense account, sometimes called a world or clearing account. You post bank +X and suspense -X when the statement arrives, then move the amount from suspense to the client's receivable when you match it. The suspense balance is now a to-do list: everything sitting there is money you have received and not yet explained. That is a useful number to watch, and a stored balance column can never give it to you.
Two checks belong in the nightly job and in the test suite. No transaction sums to anything but zero. And the suspense account is empty, or every item in it has an owner and an age. Both are cheap queries against the entries table.
Two writers, one row
Deriving the balance removes the drift, but it does not remove the race. If two requests read a balance and then decide whether a transfer is allowed, they can both decide yes. The decision and the write have to be in one transaction, and the row they depend on has to be locked.
In PostgreSQL, SELECT ... FOR UPDATE locks the selected rows as though for update and, in the documentation's words, "prevents them from being locked, modified or deleted by other transactions until the current transaction ends". Row-level locks "block only writers and lockers to the same row", so plain reads keep working. Under READ COMMITTED a waiting statement gets the updated row once the other transaction commits; under REPEATABLE READ it raises an error instead, and the application retries. Either behaviour is fine. What is not fine is reading without a lock and hoping.
Three habits keep this from turning into deadlocks and timeouts. Lock rows in a stable order, usually by account id, so two transfers between the same pair of accounts cannot grab them in opposite orders. Keep the transaction to database work only: no HTTP calls, no email, no queue publish while holding a lock. Set a lock_timeout so a stuck transaction fails instead of holding a row for a minute.
A conditional update does the same job with less code when the rule is simple: UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?, then check that exactly one row changed. The database evaluates the condition and the write together. It is a good pattern for a transfer between two accounts, and it stops being enough the moment the rule needs more than one row.
If you keep a balance row as a cache, keep it inside the same transaction as the entries and treat it as rebuildable. SELECT SUM(amount) FROM entries WHERE account_id = ? must produce the same number after a rebuild, and a scheduled job should prove it once a day. A cache you can verify is an optimisation. A cache you cannot verify is the bug this article is about.
Corrections are new rows
A ledger is append-only. When an entry is wrong, you do not update it and you do not delete it. You post the opposite entry, in a new transaction, with a reference to the original. The balance ends up where it should be, and the history still shows what you believed on the day you believed it. Accounting calls this a reversal, and it is the reason a ledger can survive an audit, a dispute or a change of accountant.
Two details make reversals safe in practice. Give each request an idempotency key with a unique index, so a retried HTTP call posts one transaction rather than two. And record the reason on the reversing transaction: "duplicate charge, ticket 412" is worth more in a year than a correct number with no explanation.
Payments need one more state than entries. A card payment is authorised before it settles, and a SEPA transfer can still be returned days later. Model that as a pending transaction that becomes posted, not as an entry you edit. The bank statement is the external truth; until it arrives, the ledger is your best opinion, and it should say so.
What it costs
Entries grow and balances do not, so SUM gets slower. The answers are ordinary: an index on (account_id, created_at), a monthly snapshot plus the entries after it, closing entries that roll a period into an opening balance. Reporting code gets longer, because a report now has to say whether it means "as posted" or "as of the statement".
That is the trade. You pay in rows and in a more careful reporting layer, and you get a system where a wrong number can be explained instead of overwritten. For anything holding other people's money, the second is not optional.
A worked example
We built one of these as an open reference: Danube Bank, a FastAPI and MySQL application with a customer app and a separate operator console. Every balance lives in a double-entry ledger, transfers are row-locked inside a database transaction, the console can search the ledger and reverse an entry, and fourteen automated tests check the invariants, including that the entries of every transaction sum to zero. It is a demo with no real money and no real customer data, and it is MIT licensed, so the schema is there to read if you want a starting point.
If you are fixing this in an existing product, the order that works is:
- Add the entries table and write to it, in the same transaction as the balance update, before you change anything else.
- Backfill one entry set per existing balance, with a date and a reason, so the ledger explains the number instead of replacing it.
- Add the nightly check that every transaction sums to zero, and alert on it.
- Move reads to the derived balance, then make the column a rebuildable cache or drop it.
- Only then change the interface. Users will not notice the schema, and they will notice a balance that no longer moves on its own.
Frequently asked questions
- What is double-entry bookkeeping in simple terms?
- Every movement of money is recorded twice: once as a debit and once as a credit, in two different accounts, with the two amounts offsetting each other. The result is that every transaction sums to zero, and each account's balance can be recomputed at any time from its own history. In a database it is one entries table with a signed amount per row and a rule that each transaction totals zero.
- Do I need accounting knowledge to build a ledger?
- You need two ideas and no more: a signed amount per entry, and the rule that each transaction sums to zero. Debit and credit are just the names for the positive and negative signs, and which sign increases an account depends on whether it is an asset or a liability. An accountant can name the accounts for you; the code does not change.
- Why not just store the balance and update it?
- Because an update is a second write that can fail, race or be skipped, and once it is wrong there is no way to tell what it should be. Storing a balance is fine as a cache if it lives in the same transaction as the entries and can be rebuilt from them. It is not fine as the only record of what happened, because then the number is the only evidence, and the number is the thing that drifted.
- How do I correct a wrong ledger entry?
- Post the opposite entry in a new transaction and reference the original. Never update or delete the entry, because the history is what lets you explain a balance months later, and because a deleted row cannot be shown to an auditor or a client. Record the reason on the reversal, and use an idempotency key so a retry does not post the correction twice.
- Should the balance be calculated on every request?
- Not necessarily. Summing entries is correct and simple, and it is fine for an account with thousands of movements. At millions, keep a balance row or a periodic snapshot, but keep it in the same transaction as the entries and run a job that rebuilds it from scratch and compares the result. The rule stays the same: the entries are the truth, and anything else must be reproducible from them.
- What data type should I use for money in the database?
- An integer in the smallest unit, such as bani for lei, or a fixed-precision decimal type. Never a floating-point type, because binary floating point cannot represent most decimal fractions exactly and the error accumulates across thousands of entries. Keep the currency code on every entry, and post currency conversion as its own pair of entries at the rate you actually received.


