Blog

A balance that cannot go negative

2026-07-29

An isometric illustration of a balance scale with two pans, level

A player has 100 chips and places two 80-chip bets at the same moment. Two requests, both read a balance of 100, both see enough, both deduct. The player now has −60 chips and you have a bug that only appears under load.

pithy add ledger gives every user a balance for chips, gold, gems, credits or tokens, in your own D1. Three properties make it safe, and the third is the one people leave out.

Atomic

A movement is one operation. There is no read-then-write window for a second request to slip into, so the scenario above resolves the way you would want: one bet succeeds, one is refused.

Idempotent

A payout delivered twice pays once.

This is not a nicety. Anything that awards currency will eventually be retried — a network blip, a client that did not see your response, a webhook redelivered, a Workflow step that ran again after a restart. If your ledger takes the second delivery at face value, retries mint money.

Making the movement idempotent means the safe thing and the easy thing are the same thing. Callers can retry freely, which is exactly what you want them doing.

Overdraft-safe, in the database

Here is the part I want to single out.

Balance cannot go negative because of a CHECK constraint on the table. Not a validation in the service layer. Not a guard in the movement function. A constraint the database itself enforces.

The difference is what happens when somebody writes a new code path. Application-level checks protect the paths that remember to call them, and the whole history of financial bugs is code paths that did not. A constraint protects every path, including the migration somebody runs by hand at 11pm, including the admin tool written in a hurry, including the one you have not written yet.

If a movement would take a balance below zero, the write fails. There is no configuration to disable it and no code path that skips it.

Holds are what make wagering work

The three properties above make a balance safe. Holds make it usable for anything where money is committed before the outcome is known.

A bet is placed. The stake is not spent yet — the game has not resolved. But it must not be spendable either, or the player bets the same chips at three tables and you find out which one loses when the balance goes.

So a hold reserves the stake at the moment the bet is placed. Then, when the game resolves, it is either:

  • released — the hold is lifted, the funds are available again, nothing moved; or
  • captured — the hold becomes a real debit.
stateDiagram-v2
    direction LR
    [*] --> Available
    Available --> Held: a bet is placed
    Held --> Available: released, nothing moved
    Held --> Debited: captured, a real debit
    Debited --> [*]

    note right of Held
      Not spent, and not spendable.
      The same chips cannot ride
      at three tables.
    end note

That is the whole primitive, and it is why this pairs with multiplayer’s wagering stack. The game decides who won. The ledger already had the stakes reserved before anyone knew.

Without holds you are choosing between deducting up front and refunding losers, which turns every crash into a lost refund, or deducting at resolution, which lets players spend money they have already committed.

Currency-agnostic, and what that means for you

The ledger does not know whether a unit is a gem or a dollar. It moves integers between balances with the guarantees above.

That boundary is deliberate. Building the accounting correctly is a solvable engineering problem. Deciding what you are allowed to do with it is not one a library can answer.

Scoping

Reads scope to the caller: a player sees their own balance.

Moving another player’s balance requires the admin scope. So a support tool can credit an account and a game client cannot, which is the distinction that has to hold for any of the above to matter. Compose auth too — without it there is no caller to scope to.

What it does not do

It is not double-entry bookkeeping across accounts. It is a per-user balance with a movement history, which is the right shape for game currency and the wrong shape for a general ledger you would hand an accountant.

It does not do currency conversion. Two currencies are two balances, and the exchange rate between them is your policy.

And it does not price anything. What a thing costs is your game’s design, not the ledger’s business.