Double-entry as a data model, not an accounting concept
15 min read
Somewhere in your codebase there is a function that moves money, and inside it there are two statements:
update accounts set balance = balance - 100 where id = $1;
update accounts set balance = balance + 100 where id = $2;
Wrapped in a transaction, so it's atomic. Both happen or neither does. People stop worrying at this point, and the code ships, and for a year it's fine.
It isn't fine. It's just that the failures are quiet and they arrive later. That pair of statements has three problems, and only one of them is about correctness.
The balance is a claim with no evidence behind it. If the number is wrong, nothing in the database tells you when it went wrong, or by how much, or which code path did it. There is no wrong-ness to detect: the column says what it says. A support ticket about a customer being nine pence short is now an archaeology project across application logs, if you still have them.
The second problem is that half the movements in a real system aren't two-sided. A fee comes out of the middle. A payment goes to a provider you don't have a row for. So somebody adds a third update, and somebody else adds one with no counterparty at all, because the money went "out". Money that goes out goes somewhere, and if your schema has no name for that somewhere, the money leaves your books and the books stop adding up.
Third: the code is the only thing enforcing any of this. The invariant lives in whoever remembers to write both statements.
Double entry fixes all three, and it isn't an accounting concept you have to translate into engineering. It's already a data model. It has been one since 1494, which makes it older than every idea in your stack and better tested than most of them.
One invariant, not a vocabulary
The reason double entry reads as accountancy is the vocabulary: debits, credits, T-accounts, journals, ledgers. None of that is the idea. Strip it and one sentence remains:
Every transaction consists of entries against named accounts, and those entries sum to zero.
That's it. It's an invariant over a table, of the kind you'd happily write a check constraint for in any other domain. What you get from it is what it forbids: money cannot be created, destroyed or made to vanish in transit, because there is no way to write a legal transaction that does any of those things. An amount that leaves one account is, by construction, in another one, and the schema will not let you skip naming it.
Two corrections to the usual mental model follow immediately.
Double entry does not mean two rows. It means balanced rows. A payment with a fee has three legs and is perfectly ordinary:
Customer -100.00
Merchant payable +97.50
Fee income +2.50
Sums to zero, so it's valid. Systems that hardcode a from_account and to_account column pair have quietly assumed two, and every fee, tax, split and FX conversion after that gets modelled as several separate transfers that are only related by convention.
The entries are the data. The balance is not. A balance is a sum() over entries. The moment you store it as an independently writable column, you have two sources of truth and no way to say which one is lying.
Name the outside world
The step that decides whether this works is boring and gets skipped: you need accounts for things that are not yours.
The customer's card, the acquiring bank, the tax authority, your own corporate account at a real bank, the fee revenue you just earned. If money moves between your system and any of them, they need to be accounts, or you will write a single-sided entry and the invariant dies on the first real feature.
This feels wrong the first time. You don't control Stripe's balance sheet, so why is there a row called provider_clearing? Because your books are a model of your position, not of theirs. provider_clearing doesn't record what the provider has. It records what you believe the provider owes you, which is a fact about you, and which you will later prove or disprove against their statement. That is exactly the number reconciliation compares, and a system without such an account has nothing to reconcile with.
So account types split into two families, and the difference is operational rather than philosophical:
- Internal accounts hold value you can inspect directly. Customer wallets, your fee income, the suspense account holding money in flight.
- External accounts hold your claim on somebody else. Their true value is only knowable from a statement, which arrives later and sometimes disagrees.
Once the outside world is on the chart of accounts, every movement has two ends, and "where did the money go" stops being a question you answer from memory.
Two tables and a constraint
The schema is smaller than the discussion around it.
create table accounts (
id uuid primary key,
name text not null unique, -- 'customer:1234:GBP', 'fee_income'
kind account_kind not null, -- asset | liability | income | expense | equity
currency char(3) not null
);
create table transactions (
id uuid primary key,
occurred_at timestamptz not null, -- when the event happened
recorded_at timestamptz not null default now(),
external_ref text unique, -- the idempotency handle
description text not null
);
create table entries (
id bigserial primary key,
transaction_id uuid not null references transactions(id),
account_id uuid not null references accounts(id),
amount bigint not null check (amount <> 0), -- signed minor units
currency char(3) not null,
created_at timestamptz not null default now()
);
A few of those columns are doing more work than they look like they are.
amount is a signed integer in minor units, and the sign convention is into the account is positive. It is never a float, for the reasons that need their own post, and it is never zero, because a zero entry is either a bug or a comment.
occurred_at and recorded_at are separate on purpose. A transaction booked on Monday for a card authorisation from Saturday has two different times, and every question about "what did we think the balance was at midnight" needs the second one. Collapsing them into a single created_at is a decision you can't reverse without a data migration, because the information was never captured.
external_ref carries the unique constraint that makes writing a transaction idempotent. The contract around it matters more than the column does, but this is where the guarantee is enforced.
And the invariant itself:
create function assert_transaction_balances() returns trigger as $$
declare
offending record;
begin
select currency, sum(amount) as total
into offending
from entries
where transaction_id = new.transaction_id
group by currency
having sum(amount) <> 0
limit 1;
if found then
raise exception 'transaction % is unbalanced: % %',
new.transaction_id, offending.total, offending.currency;
end if;
return null;
end;
$$ language plpgsql;
create constraint trigger entries_must_balance
after insert on entries
deferrable initially deferred
for each row execute function assert_transaction_balances();
deferrable initially deferred is the whole trick. The check runs at commit, not per row, so you can insert legs one at a time and still be told off if the set of them doesn't balance. Without deferral the constraint is unsatisfiable: the first leg of any transaction is unbalanced by definition.
Note that it groups by currency. That matters later.
Enforce it in the database, or don't claim it
There's a version of this design where the balancing check lives in a Ledger service class, and every write goes through it, and the class is well tested. It's a reasonable-looking argument and I think it's wrong, for a reason that has nothing to do with trusting your own code.
The database is the only component that sees every write. The service layer sees the writes that went through the service layer. Over a few years a ledger acquires: a data migration, a backfill script, an admin console with a "fix balance" button written during an incident, a second service in a different language, an analytics job that turned out to write, and one afternoon where somebody ran an INSERT in a psql session to unblock a customer. Every one of those bypasses your class. None of them bypasses a constraint trigger.
So the rule is that the invariant lives where it cannot be routed around. The application layer can validate early for better error messages, which is a UX decision. The trigger is the one that's load-bearing.
The cost is real and worth stating plainly. Balance checking in a trigger costs you a query per transaction commit, you now have business logic in PL/pgSQL that your test suite has to cover through the database rather than in memory, and the failure surfaces as an exception at COMMIT in a place your ORM's error handling probably doesn't expect. In exchange, "the ledger is unbalanced" becomes a sentence that cannot be true. That trade is worth it in every system I've worked on where the numbers were money, and in approximately none where they weren't.
Append-only is the expensive half
The second constraint is that entries are never updated and never deleted.
revoke update, delete on entries, transactions from app_user;
This is the part that people agree with in principle and then find genuinely painful in practice, so it's worth being honest about what it costs before recommending it.
You get a call: an entry was booked against the wrong account. Under a mutable schema this is a thirty second UPDATE. Under append-only it's a reversing transaction that puts the money back, then a second transaction that books it correctly, and both of them are permanently visible in the customer's history. Six entries where one edit would have done, and a statement that shows the mistake happening and being fixed.
That visible mistake is the feature. An UPDATE on a ledger destroys the evidence of what you previously believed, and what you previously believed is the thing an auditor, a dispute, or a bug investigation is asking about. A ledger you can edit answers "what is true now". A ledger you can only append to answers "what did we know, and when did we know it", which is the question that gets asked when something has gone wrong.
There's a smaller and more practical benefit: append-only means the table is safe to stream. Every downstream consumer, the analytics warehouse, the balance cache, the fraud model, can follow the entries table forward and never has to handle a row changing underneath it.
Two rules keep the append-only story honest:
- A reversal is a new transaction, not a flag on the old one. Give it a
reverses_transaction_idso the pair is queryable, and let both stand. - Nothing is backdated. A correction discovered today is recorded today, with
occurred_atpointing at the original event. That pair of timestamps is why they're separate columns.
The balance becomes a query
With a mutable column gone, a balance is derived:
select sum(amount) from entries
where account_id = $1 and currency = $2;
The objection is immediate and correct: that's a full scan of an account's history, forever, on the hottest read path you have. A three year old high-volume account will not serve this in single digit milliseconds.
The answer is not to bring back the column. The answer is that a derived value can be cached, and a cache can be checked, which is the property the column never had. Two approaches, and I'd pick between them on write contention rather than read speed.
Checkpoints. Periodically write a row saying "as of entry 4,812,006 this account held 812.40". The balance is that snapshot plus everything after it.
create table balance_checkpoints (
account_id uuid not null,
currency char(3) not null,
as_of_entry_id bigint not null,
balance bigint not null,
primary key (account_id, currency, as_of_entry_id)
);
Reads sum a bounded tail. Checkpoints are derived, so a wrong one is repaired by deleting it and recomputing, which is not a sentence you can say about accounts.balance. This is my default: nothing on the write path changes, and the cache is disposable.
Running balance on the entry. Each entry carries the account's balance immediately after it, with a (account_id, sequence) unique index to force serialisation. Reads are a single indexed row and statements come out right without any arithmetic. The cost is that every write to an account now contends on that account's latest row, which concentrates the contention on precisely the accounts everyone touches: the fee account, the merchant settlement account, the treasury account.
Either way, the reconciliation between cache and truth is a query you can run continuously:
-- A checkpoint claims the sum of every entry up to as_of_entry_id.
-- Recompute it and the drift must be zero.
select c.account_id,
c.balance - coalesce(sum(e.amount), 0) as drift
from balance_checkpoints c
left join entries e
on e.account_id = c.account_id
and e.currency = c.currency
and e.id <= c.as_of_entry_id
group by c.account_id, c.balance
having c.balance - coalesce(sum(e.amount), 0) <> 0;
The exact shape matters less than the fact that it exists. A cached balance you can verify against the entries is a performance optimisation. A stored balance you can't is a liability with a nice index on it.
Signs, and the debit/credit conversation
At some point a finance person will look at your amount column and ask where the debits and credits are, and there's a real decision behind that.
Store one signed column. Two nullable debit and credit columns give you a state space with three illegal configurations (both null, both set, both zero) and no arithmetic without a coalesce. Debit and credit are a presentation of the sign, so present them:
select account_name,
case when amount > 0 then amount end as debit,
case when amount < 0 then -amount end as credit
from entry_view where transaction_id = $1;
The part that catches engineers is that the sign of a good balance depends on the account's kind. A customer's wallet is your liability: you owe them that money. Money arriving from a customer increases what you owe them, so on your books it's a credit, even though the customer's app shows a bigger number and everyone involved is happy. Your bank account is an asset and works the intuitive way round.
This is why kind is on the accounts table. The sign is stored raw and consistent; the interpretation of the sign, whether a balance is "normal" or worrying, is a function of the account's kind, and it belongs in the reporting layer where a human is reading it. Don't push it into the entry rows and don't flip signs on the way in to make one screen read nicely, because the moment two screens disagree about the convention, the invariant reduces to noise that happens to sum to zero.
Currencies don't add up
Sum-to-zero is per currency, not per transaction. This is the constraint people relax when the first FX feature lands, and relaxing it is how you get a ledger that balances in a currency that doesn't exist.
A GBP-to-USD conversion has four legs, not two:
Customer GBP wallet -100.00 GBP
FX position GBP +100.00 GBP
FX position USD -126.40 USD
Customer USD wallet +126.40 USD
GBP sums to zero. USD sums to zero. Nothing pretends the two amounts are equal, because they aren't: they're equal at one rate, at one instant, which is a fact about the market rather than about the money.
What you gain is that the FX position accounts now hold a real number: your exposure in each currency, live, from the ledger rather than from a spreadsheet. If GBP and USD positions don't offset at the current rate, the difference is your FX gain or loss, and it was always going to exist. The choice is only whether it's a line item or a mystery.
The rule that keeps this clean: an account has exactly one currency, and it's in its name. customer:1234:GBP and customer:1234:USD are two accounts. Multi-currency accounts are how you end up with a sum(amount) that means nothing.
The invariant you can run in production
Here is the payoff, and it's a single query:
select currency, sum(amount) from entries group by currency;
Every row must be zero. Not "should be", must: it's the same statement as the trigger, evaluated over the whole table instead of one transaction. Run it on a schedule against production. If it ever returns a non-zero row, something has bypassed the constraint, and you want to hear that from a monitor rather than from a customer.
There's a family of these, and they're worth writing down as the ledger's actual specification:
- Every currency sums to zero across all entries.
- Every transaction sums to zero, per currency (the trigger, re-checked in bulk).
- No account whose kind forbids it is negative. Customer wallets, usually. Suspense accounts, usually not.
- Every checkpoint equals the recomputed sum at its entry ID.
- Every entry belongs to a transaction that exists, and no transaction has one leg.
Each is one query and none takes more than a few minutes to write. The set of them is a stronger correctness statement than most financial systems have, and unlike unit tests it's a claim about the real data.
The single-leg check is worth calling out. Under the deferred trigger it should be impossible, but it catches the specific incident where somebody disabled the trigger for a bulk load and the load half-failed. That has happened to me. The check found it in eleven minutes.
What this doesn't buy you
- Not correct amounts. A perfectly balanced transaction can move £10,000 instead of £100.00. Sum-to-zero has no opinion about magnitude, and a decimal shift passes every constraint in this post.
- Not the right accounts. Money moved from the wrong customer to the wrong merchant balances beautifully.
- Not reconciliation. The ledger being internally consistent says nothing about whether it matches the bank. Internal consistency is a precondition for that comparison, not a substitute.
- Not idempotency. The unique
external_refis what stops a duplicate transaction. Double entry will happily record the same payment twice, in perfect balance, both times. - Not performance for free. You've traded a cheap read and an unverifiable number for an expensive read and a checkable one, and you'll spend real work on the caching.
- Not an accounting system. You have a ledger, which is the substrate. Period ends, trial balances, revenue recognition and a chart of accounts a finance team has signed off on are all still ahead of you.
What it does buy is a floor. Below a certain kind of catastrophe you cannot go, and the class of bug where money quietly evaporates between two UPDATE statements has been designed out rather than tested for.
The short version
- The mutable
balancecolumn is the bug. It's a claim with no evidence and nothing to check it against. - Double entry is one invariant: entries against named accounts, summing to zero. The vocabulary is optional.
- Balanced does not mean two rows. Fees, splits and FX have three or four legs and are perfectly normal.
- Give the outside world accounts, or you'll write a single-sided entry in week one.
- Enforce sum-to-zero with a deferred constraint trigger, because the database sees every write and your service layer doesn't.
- Never update or delete an entry. Corrections are new transactions, and the visible mistake is the point.
- Separate
occurred_atfromrecorded_atat the start. You cannot recover the distinction later. - Balances are derived. Cache them with checkpoints you can recompute, never with a column you can't verify.
- Store a signed amount; derive debit and credit for display. The sign of a good balance depends on the account's kind.
- Sum-to-zero is per currency. One currency per account, and FX gets a position account on each side.
select currency, sum(amount) from entries group by currencymust return zeros in production, on a schedule, forever.
Two things to go and look at today. Find every place in your codebase that writes to a balance column and count them; if there's more than one, you already have two definitions of what a balance is, and the reason nobody has noticed is that nothing in the system is able to. Then take last month's data and try to answer, in SQL alone, where a specific £50 went. If the answer requires application logs, the ledger isn't a record of what happened, it's a summary of what you meant.