A balance is a query, and a hold is not a column
Custodial systems lose money in two ordinary writes: UPDATE accounts SET balance = balance + :amount, and the one next to it that nudges a frozen column up and down while a withdrawal is pending. Both are read-modify-write against a cell that several code paths believe they own, and both turn an accounting question into a concurrency question. The fix is not a better lock. It is to stop storing the answer: derive the balance from paired postings, and make a reservation a first-class object that settles or releases, rather than a number parked beside the balance.
I have been building payment and custodial systems in production since 2018 — per-user virtual account ledgering with separate borrowing and investing sides, attribution of incoming transfers, custodial wallets for BTC and ETH, stablecoin rails in daily use. Nearly every reconciliation bug I have chased in that time was a variation of one thing: a number was stored as authoritative, and more than one path was allowed to edit it. I put the shape I keep re-deriving into a small library, ledger-core, and I will use it here, because it is easier to argue with code than with adjectives.
The constraint: a withdrawal is two moments, not one#
The thing that makes custodial balances harder than they look is that the interesting states are not moments. A card deposit is one movement. A withdrawal is two: the funds stop being spendable now, and they leave later — or they never leave, because the payout failed, or compliance stopped it, or the customer cancelled while the batch was still queued.
If the only representation you have is a balance cell, you are forced to encode that gap somewhere. The usual answer is a second cell. Now every code path has to remember to touch both, atomically, in the right order, and the invariant that used to be arithmetic becomes procedure — something enforced by everyone remembering. That is the shape that fails under retry, under partial failure, and under two operators clicking at once.
So the design has two halves. Postings are the only thing written. Everything else — the balance, what is reserved, what may be spent — is derived from them.
Movements: two sides, one id, no partial writes#
A movement has two sides and one id. Nothing in the system writes one side of it:
from datetime import datetime, timezone
from decimal import Decimal
from ledger_core import Account, AccountType, Entry, Journal, Money
cash = Account("cash", AccountType.ASSET, "EUR")
customer = Account("customer:42", AccountType.LIABILITY, "EUR")
deposit = Entry.transfer(
entry_id="e-1",
occurred_at=datetime.now(timezone.utc),
debit=cash,
credit=customer,
amount=Money(Decimal("25.00"), "EUR"),
memo="card deposit",
)
journal = Journal()
journal.append(deposit)An entry that does not net to zero in every currency it touches raises UnbalancedEntry at construction. That is the part worth sitting with: the invalid object does not exist. There is no window in which a one-sided movement is in memory waiting for a validator to catch it, no repair job that finds orphaned legs at 3am. More than two postings are fine — fee splits, multi-party settlements — as long as they sum to zero.
Batches inherit the rule. Journal.extend is all-or-nothing: a duplicate id or a correction of something nobody wrote rejects the batch, not the offending row. Half-applied imports are the single most expensive category of reconciliation work I have done, and they are almost always the residue of a loop that committed per item.
Entries are never edited or deleted. To undo one, you write the entry that undoes it:
refund = deposit.reversal(
entry_id="e-2",
occurred_at=datetime.now(timezone.utc),
)
journal.append(refund)This is not purity for its own sake. An append-only journal is what lets you answer why a balance is what it is, months later, when a customer disputes it — and the answer is the same one your finance side will compute independently.
Balances: derived, snapshotted, never edited#
A balance is what the entries add up to, counted in the account's normal direction:
from ledger_core import Balances
balances = Balances(journal)
balances.balance(cash) # Decimal("25.00")Reading writes nothing, so two readers cannot overwrite each other's arithmetic — which is exactly how a mutable column updated by read-modify-write silently loses a movement. The lost update is not a rare race in a busy custodial system; it is the normal outcome of a webhook redelivery landing next to a manual adjustment.
The objection to deriving is performance, and it is a real objection: folding the whole journal on every read gets slower as the journal grows. The answer is to cache the fold, not to abandon it. Each fold is kept as a snapshot — how far it got and what it had by then — and the next read resumes from there, replaying only what has been written since:
snapshot = balances.snapshot(customer)
snapshot.total # what the account held
snapshot.through # how many entries are folded into it
snapshot.as_of # when the last of them occurredSnapshots are plain values. They can be stored, shipped between processes and handed back: Balances(journal, [snapshot]). The discipline around them is what keeps this from becoming a stored balance in disguise. A snapshot is only ever replaced by one that reaches further, never edited, and one claiming more entries than the journal holds is refused with SnapshotMismatch. A stale snapshot therefore costs a replay, never a wrong answer. That asymmetry — degrade into slowness, never into incorrectness — is the property I want from every cache that sits in front of money.
One deliberate exception: a balance as of a moment ignores snapshots entirely and replays in full, because a position in the journal says nothing about a point in time. Entries can be appended with earlier occurred_at values than the ones before them; back-dated settlement files do this routinely.
balances.at(customer, datetime.now(timezone.utc))Holds: a reserve with a state, not a number#
Now the second half of the thesis. A hold writes nothing to the ledger. It stands in front of the balance:
from ledger_core import Holds
holds = Holds(journal)
holds.place(
hold_id="h-1",
account=customer,
amount=Money(Decimal("10.00"), "EUR"),
placed_at=datetime.now(timezone.utc),
memo="withdrawal to IBAN",
)
holds.balance(customer) # what the account holds
holds.held(customer) # what open holds have reserved
holds.available(customer) # balance minus holdsavailable is the number every spend decision should be asking about, and it is derived too — balance less everything still open. A hold that does not fit raises InsufficientFunds: the reservation is refused rather than the account going short. Refusing at reservation time is the whole point. If the check lives at capture time instead, you have already told the customer their withdrawal is in progress.
The Hold itself is a frozen record with a state — OPEN, RELEASED, CAPTURED — and it settles exactly once. released() and captured() return a new hold rather than mutating one, and calling either on an already-settled hold raises HoldNotOpen. Compare that to a frozen_amount column: a double release against a column is an arithmetic error that shows up days later as a balance that no longer reconciles, while a double release against a state machine is an exception at the call site.
If the payout falls through, the reservation goes back:
holds.release("h-1", at=datetime.now(timezone.utc))If it goes through, the hold is settled by the entry that moves the money — the same paired posting as any other movement, no special case. The settled hold keeps a reference to the entry that settled it, so "which movement discharged this reservation" is a stored fact rather than a join on amount and timestamp. And a settling entry that does not move exactly what was held is refused with CaptureMismatch, which closes the gap where a partial capture quietly leaves stranded funds reserved forever. Every long-lived custodial system I have worked on accumulates those: reservations nobody can explain, on accounts nobody can release.
Holds read through the same projection as balances. Pass Holds(journal, balances=balances) to share snapshots with an existing projection, or let it build its own — but there is only ever one arithmetic, not a reserve calculation that has drifted from the balance calculation.
The money type the ledger refuses to own#
The third piece is the one people argue with most, so it is worth stating plainly: the ledger should not own a money type. It posts any value that exposes an exact amount, the currency it is denominated in, and +, -, unary - and ordering. That is the MoneyLike protocol, and it is the entire contract.
This is not abstraction for its own sake. Money types are opinionated and domain-specific — minor-unit precision, rounding policy, whether eight decimal places are normal, how currency codes are compared. A ledger that ships its own Money forces every system that adopts it into a conversion layer at the boundary, and conversion layers between two money types are where rounding differences are born. Accepting a protocol means the type you already trust is the type that gets posted.
In ledger-core there is a small stand-in Money so the library stays usable and testable on its own, with the real pairing behind an extra:
pip install "ledger-core[crypto]"Swapping is an import change; nothing else in the API moves.
The refusal has one visible consequence I like a lot. balance, held and available come back as Decimal in the account's own currency, not as money objects — because the ledger owns no money type, it cannot mint the zero that an empty balance would need. The return type tells you the truth about where the boundary is instead of papering over it.
What I would do differently#
On earlier systems I reached for the stored balance first and the ledger second, usually with a nightly job that recomputed from history and flagged discrepancies. That job is a confession: it exists because the primary number is not trustworthy. It also arrives too late — by the time it flags drift, the wrong number has already been shown to a customer, used in a spend decision, and possibly settled against.
The other thing I would change earlier is treating holds as a feature of the withdrawal flow rather than a concept of the ledger. When the reserve belongs to one flow, the second flow that needs one — a pending trade, a compliance freeze, a chargeback provision — invents its own, and available stops being a single answer. Making the hold a ledger-level object with its own id, state and settling entry is what keeps a fifth reservation type from being a fifth column.
What I would keep: derivation with a snapshot in front of it, snapshots that can only move forward, and validation at construction rather than at commit.
Close#
Stored balances and freeze columns feel like performance decisions. They are not; they are decisions to make a derived fact editable, and everything expensive that follows — the lost updates, the repair jobs, the stranded reservations, the nightly recompute nobody trusts — descends from that. Postings are the only thing worth writing down. The balance is a question you ask them, and a hold is an object that stands in front of the answer until it settles or goes away.