Your Balance Check Is a Race Condition
Reading a balance and then deciding is not a check, it is a guess with good manners. I graded four ledgers I had built and none of them enforced their most important rule where it actually needed enforcing.
Everyone has written this function.
async function payout(accountId, amount) {
const balance = await getBalance(accountId)
if (balance < amount) {
throw new Error('Insufficient funds')
}
await debit(accountId, amount)
await sendToProvider(accountId, amount)
}
It reads well. It passes review. It has a test, and the test passes.
It is also broken, and it is broken in a way your test suite is structurally incapable of noticing.
The gap you cannot see
There is a gap between reading the balance and writing the debit. In that gap, the balance is a number you used to know.
Two payout requests arrive for the same account at the same moment. The account holds 100. Each request is for 80.
Request A reads 100. Request B reads 100. Both are correct at the moment they read. Both compare against 80, both are satisfied, both proceed. Two debits land and the account sits at negative 60.
Nothing threw. Nothing logged. Your monitoring is quiet because from the application's point of view, two entirely valid payouts happened. You will find out days later during reconciliation, or you will find out because someone worked out they could hit the button twice.
This is check-then-act, one of the oldest bugs there is, wearing a suit.
The reason it survives review is that it is invisible under the conditions we normally test. Tests run serially. Local development is one request at a time. Staging has no traffic. The bug requires two things to happen inside the same few milliseconds, which is rare enough to never show up until you have volume, and then it is not rare at all.
The fixes that feel right and are not
Most teams reach for one of these first.
"I wrapped it in a transaction." A transaction gives you atomicity and rollback. It does not give you mutual exclusion. Under Postgres's default isolation level, READ COMMITTED, both of those transactions happily read 100, and both happily write. A transaction means your two writes land together or not at all. It says nothing about whether somebody else was reading the same row while you decided.
"I'll bump the isolation level." SERIALIZABLE genuinely does prevent this. It also throws serialization failures under contention, which means every call site needs retry logic. If you adopt SERIALIZABLE without adding retries, you have converted a silent correctness bug into a loud availability bug. That is an improvement, but it is not a fix, and it is usually not the fix people think they are shipping.
"I'll add a lock in the application." A mutex works beautifully until the day you run a second instance. Which is the same day you get the traffic that made the race condition reachable in the first place.
The pattern in all three is the same. They try to protect the invariant somewhere near the code that happens to be thinking about it, rather than at the place where the data actually changes.
Where the rule has to live
The fix is not clever. Put the constraint where the write happens.
At minimum, take the row:
SELECT balance FROM account_balances
WHERE account_id = $1
FOR UPDATE;
Now the second transaction blocks until the first one commits, and it reads the balance that actually exists rather than the one that used to. The check and the act stop being two separate moments.
Better still, make the illegal state unrepresentable:
ALTER TABLE account_balances
ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);
Now it does not matter whether your application remembered to check. It does not matter whether the caller was a payout service, a batch job, a migration, or an engineer with a psql session open on production at two in the morning. The write fails. The database refuses.
For double-entry specifically, the invariant that matters most is that every transaction's lines sum to zero. That one wants a deferred constraint trigger, so it runs once at commit rather than fighting you halfway through inserting the second leg:
CREATE CONSTRAINT TRIGGER transaction_must_balance
AFTER INSERT ON journal_lines
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_transaction_balances();
Now an unbalanced transaction cannot be committed by anyone, through any path, ever.
The part where I indict myself
I want to be straight about how I learned this, because I did not learn it from a textbook.
Last year I went back through my own work and graded four different ledger implementations I had built across different products, on a fixed rubric. Double-entry enforcement, idempotency, locking, test coverage, maturity. The intention was to pick the best one and extract it rather than write a fifth.
The result was uncomfortable. Not one of the four enforced debits equals credits at the database level. Every single one enforced it in application code. Four independent implementations, built by me, at different times, for different products, and every one of them had the same gap.
They were not sloppy. They had tests. The balance logic was correct. In each case the accounting rules lived in a service layer that was careful and well reviewed, and in each case the actual guarantee was "as long as every future write goes through this function."
Which is not a guarantee. It is a hope with good test coverage.
And it does not survive contact with reality, because the writes that break your invariant are almost never the ones going through your careful service. They are the backfill script somebody ran once. The migration that touched rows directly. The admin tool written in a hurry. The incident where someone fixed data by hand at 3am because the alternative was worse.
That audit changed how I define the word enforced. An invariant is only enforced if it holds against a hostile connection. If a determined engineer with production credentials can violate it, you do not have an invariant, you have a convention.
What it costs
I would rather not pretend this is free.
You have to think about lock ordering. The moment you take row locks, you can deadlock. Transactions that touch several accounts need to acquire them in a deterministic order, usually sorted by id. This is a real design constraint that you now carry forever.
Some operations need explicit exemptions. Reversals restate history and sometimes legitimately need to move an account below a floor it should not normally cross. Those exceptions have to be deliberate and narrow, not a flag someone can pass.
Your tests have to actually run concurrently. A test that calls the function twice in sequence proves nothing about any of this. You need tests that fire genuinely parallel writes at the same row and assert that exactly one wins. In Go I run these under the race detector; the equivalent in other stacks is to spawn real parallel workers against a real database. Not mocks. Mocks cannot have a race, which is precisely why they are useless here.
Some flexibility genuinely goes away. That is the trade. You are converting things that used to be possible into things that are impossible, and occasionally you will want one of them back.
This is not really about money
Money makes the stakes obvious, but the shape of the bug is everywhere.
The last seat on a flight. The last unit in stock. A coupon that can only be redeemed once. A rate limiter. Any idempotency key. Anywhere you have written the words "check whether we can, then do it," you have this bug, and whether it has bitten you yet is a question about your traffic rather than your correctness.
The general form is this: if the rule lives in the code that reads, it is advice. If it lives where the write lands, it is a rule.
My ledgers all knew the correct accounting. They just kept it in the wrong place, and I did not notice across four attempts, because in ordinary operation the difference between advice and a rule never shows up.
It shows up at volume. Or when someone is trying. Or at 3am, when the person with production access is tired and just needs the number to be right.