Money: ledgers and exactly-once
A transfer must move money once, even when the client retries, copies arrive together or a process dies mid-way. A double-entry ledger in Postgres makes each of those cases safe.
- 1Money is an integer count of minor units. Every movement is an entry whose postings sum to 0.
- 2Every money request carries an idempotency key, claimed in the same transaction as the postings.
- 3Lock the accounts in id order. Let a CHECK constraint refuse an overdraft.
- 4Exactly-once is an effect: at-least-once delivery plus idempotent processing.
- The client cannot tell a lost request from a lost reply.
- Load balancers, SDKs and users all retry.
- The server must recognise the second attempt as the same request.
A timeout does not tell the client whether the money moved. Without a key, the retry moves it again.
| method | where | status |
|---|---|---|
| UPDATE a balance column, no history | Postgres | Not approved |
| Amounts as floats | Service | Not approved |
| Integer minor units in a bigint | Postgres | Approved |
| Double-entry postings, sum 0 per entry | Postgres | Approved |
| Balance as SUM(postings) on every read | Postgres | Few postings |
| Cached balance, same transaction as postings | Postgres | Approved |
| Lock the payer, then the payee | Postgres | Not approved |
| Lock both accounts in id order | Postgres | Approved |
| Conditional UPDATE WHERE balance >= amount | Postgres | Rows in id order |
| SERIALIZABLE, retry on 40001 | Postgres | Low contention |
| Idempotency key with a unique index | Postgres | Approved |
| Dedupe key in Redis only | Redis | Not approved |
The Redis key and the postings live in two stores, so a crash between them breaks the guarantee.
I use a double-entry ledger with integer amounts, an idempotency key per request, and accounts locked in id order.
-- Every entry balances: per currency, its postings sum to zero. Checked at commit.
CREATE FUNCTION entry_balances() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (SELECT 1 FROM postings WHERE entry_id = NEW.entry_id
GROUP BY currency2 HAVING sum(amount) <> 0) THEN
RAISE EXCEPTION 'entry % does not balance', NEW.entry_id USING ERRCODE = 'check_violation';
END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER entry_balances AFTER INSERT ON postings
DEFERRABLE INITIALLY DEFERRED1
FOR EACH ROW EXECUTE FUNCTION entry_balances();- 1The check runs at commit, after every posting of the entry is in. An entry that does not sum to 0 fails the commit.
- 2Each currency sums to 0 on its own, so an FX entry with 4 postings passes.
- amounts
- bigint cents. The largest is 9,223,372,036,854,775,807, about 92 quadrillion dollars.
- signs
- A posting adds its amount to the account: money out is negative, money in is positive.
- growth
- 2 postings per transfer. Partition postings by month once history is large.
-- Money is an integer count of minor units (cents). No floats anywhere.
CREATE TABLE accounts (
id bigint PRIMARY KEY,
name text NOT NULL,
currency char(3) NOT NULL,
allow_negative boolean NOT NULL DEFAULT false,
balance bigint NOT NULL DEFAULT 0, -- cached: the sum of this account1's postings
held bigint NOT NULL DEFAULT 0, -- authorized, not yet captured
UNIQUE (id, currency),
CHECK (held >= 0),
CONSTRAINT no_overdraft CHECK (allow_negative OR balance - held >= 0)2
);
-- One row per money movement. The key makes a retried request safe.
CREATE TABLE journal_entries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
idempotency_key text NOT NULL UNIQUE3,
fingerprint text NOT NULL, -- the request the key was first used for
kind text NOT NULL,
external_ref text UNIQUE -- the processor's id, for reconciliation
);
-- Each entry has two or more postings. A posting adds its amount to one account.
CREATE TABLE postings (
entry_id bigint NOT NULL REFERENCES journal_entries (id),
account_id bigint NOT NULL,
currency char(3) NOT NULL,
amount bigint NOT NULL CHECK (amount <> 0),
PRIMARY KEY (entry_id, account_id),
FOREIGN KEY (account_id, currency) REFERENCES accounts (id, currency)4
);
-- An authorization: money set aside on an account, captured or released later.
CREATE TABLE holds (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id bigint NOT NULL REFERENCES accounts (id),
amount bigint NOT NULL CHECK (amount > 0),
idempotency_key text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'held' CHECK (status IN ('held', 'captured', 'released'))
);- 1Updated in the same transaction as the postings. An audit compares it with SUM(amount).
- 2The database refuses an overdraft, whatever the code does. Holds count against the balance.
- 3One entry per key. A retry or a copy finds the key and returns the first entry.
- 4A posting must use its account currency. The pair must exist in accounts.
An entry has two or more postings that sum to 0. Each account caches its balance, and the database refuses an overdraft and an unbalanced entry.
Step 1: Request
- Make one key per user action, for example when the pay screen opens.
- Send the key with every attempt of that action.
If it fails
No reply: retry with the same key. A new key for a retry is how money moves twice.
The key is claimed first, the accounts are locked in id order, and the balances and postings commit together.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | bigint | Exact integer cents. Sums never drift. | Counters, quotas |
| Postgres | Transactions | The key, the balances and the postings commit together, or nothing does. | Every multi-row change |
| Postgres | CHECK constraint | Refuses an overdraft below the holds, even after an application bug. | Stock, seats |
| Postgres | Deferred constraint trigger | Checks at commit that each entry sums to 0 per currency. | Rules across rows |
| Postgres | Composite foreign key | A posting cannot use another currency than its account. | Tenant checks |
| Postgres | Unique index and ON CONFLICT DO NOTHING | Claims the idempotency key. A copy waits on the index, then finds the key. | Inbox tables, dedupe |
| Postgres | SELECT ... ORDER BY id FOR UPDATE | Locks every account of the entry in one fixed order: no deadlock. | Bookings, inventory |
| Postgres | Identity column | Entry ids. A rolled-back insert leaves a gap, so never read ids as a count. | Surrogate keys |
| Postgres | COPY into a temp table, FULL OUTER JOIN | Loads the processor report and matches it both ways. | Imports, data checks |
| Processor | Idempotency keys; authorize, capture, void; settlement reports | The external side of holds and reconciliation. | Refunds, payouts |
| Redis | SET NX with a TTL | Limit A fast duplicate filter in front, never the record of a payment. | Rate limits |
Postgres gives me exact integers, constraints that refuse bad money, a unique index for idempotency and row locks for transfers. All of it commits in one transaction.
transfer(key, from, to, amount):
BEGIN
insert entry with key, skip if the key exists1
IF the key existed:
IF it was another request2: RETURN key reused
RETURN the first entry // a retry or a copy
lock both accounts, in id order3 // FOR UPDATE
from.balance -= amount // CHECK refuses an overdraft4
to.balance += amount
insert postings (from, -amount), (to, +amount)
COMMIT // trigger: the postings sum to 05- 1Unique index and ON CONFLICT DO NOTHING. A copy that arrives at the same time waits here.
- 2The key is stored with a fingerprint of the request. A different amount under the same key is a client bug.
- 3Every transfer locks the lower account id first. Two opposite transfers cannot wait for each other.
- 4The UPDATE fails with a check violation. The transaction rolls back, and the key is free for a corrected request.
- 5The deferred trigger runs at commit. A bug that writes one side only cannot commit.
Tested source Go: claim the key, apply the legs · SQL: claim, lock, apply, post
// claim takes the idempotency key. If the key exists, it returns the first entry, or
// ErrKeyReused when the first request was different.
func claim(ctx context.Context, tx pgx.Tx, e Entry) (Result, error) {
var sum int64
for _, leg := range e.Legs {
sum += leg.Amount
}
if sum != 0 || len(e.Legs) < 2 {
return Result{}, fmt.Errorf("entry %q: %d legs sum to %d, want 2 or more legs that sum to 0", e.Key, len(e.Legs), sum)
}
var id int64
err := tx.QueryRow(ctx, q["claim_key"], e.Key, e.fingerprint(), e.Kind, nullable(e.ExternalRef)).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) { // the key exists: this request is a retry or a copy
var first string
if err := tx.QueryRow(ctx, q["find_entry"], e.Key).Scan(&id, &first); err != nil {
return Result{}, fmt.Errorf("find entry for key %q: %w", e.Key, err)
}
if first != e.fingerprint() {
return Result{}, fmt.Errorf("%w: %q", ErrKeyReused, e.Key)
}
return Result{EntryID: id, Replayed: true}, nil
}
if err != nil {
return Result{}, fmt.Errorf("claim key %q: %w", e.Key, err)
}
return Result{EntryID: id}, nil
}
// apply changes each cached balance and writes each posting. The CHECK refuses an overdraft,
// and the deferred trigger refuses an entry that does not sum to zero.
func apply(ctx context.Context, tx pgx.Tx, entryID int64, e Entry) error {
for i, leg := range e.Legs {
if _, err := tx.Exec(ctx, q["apply"], leg.Account, leg.Amount); err != nil {
if isCheck(err, "no_overdraft") {
return fmt.Errorf("%w: account %d", ErrInsufficientFunds, leg.Account)
}
return fmt.Errorf("apply leg to account %d: %w", leg.Account, err)
}
if _, err := tx.Exec(ctx, q["post"], entryID, leg.Account, e.Currency, leg.Amount); err != nil {
return fmt.Errorf("post leg to account %d: %w", leg.Account, err)
}
if i == 0 && e.Fault == CrashAfterDebit {
return ErrCrashed
}
}
return nil
}
-- Claim the idempotency key first. A copy of the same request waits here, then finds the key.
INSERT INTO journal_entries (idempotency_key, fingerprint, kind, external_ref)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
-- Lock every account of the entry in id order. Two transfers can never wait for each other.
SELECT id, currency FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE;
-- Change the cached balance. The no_overdraft CHECK refuses a balance below the holds.
UPDATE accounts SET balance = balance + $2 WHERE id = $1;
INSERT INTO postings (entry_id, account_id, currency, amount) VALUES ($1, $2, $3, $4);I claim the key first, lock both accounts in id order, change the balances, insert the postings and commit.
- The lab locks payer first in two sessions and gets a deadlock error each time.
- In id order, 2 workers sent 50 opposite transfers each with no error.
Opposite transfers deadlock with payer-first locking. Id order turns the cycle into a wait.
audit(): // every count must be 0
entries whose postings do not sum to 0
accounts whose balance != sum of their postings1
customer accounts with balance - held < 0
the sum of every posting in the ledger- 1Catches a cached balance that drifted, for example after manual SQL.
Tested source SQL: the audit query
-- The invariants. Every count must be 0.
SELECT
(SELECT count(*) FROM (SELECT entry_id FROM postings
GROUP BY entry_id, currency HAVING sum(amount) <> 0) e) AS unbalanced_entries,
(SELECT count(*) FROM accounts a WHERE a.balance <> (SELECT coalesce(sum(amount), 0)
FROM postings p WHERE p.account_id = a.id)) AS cached_balance_drift,
(SELECT count(*) FROM accounts
WHERE NOT allow_negative AND balance - held < 0) AS overdrawn_accounts,
(SELECT coalesce(sum(amount), 0) FROM postings) AS sum_of_all_postings;- The copies of one request ran at the same moment, each on its own connection.
- An overdraft request asked for 10 times the money in the ledger, so every copy was refused.
- No user's debits reach its starting balance, so the result does not depend on order.
Thousands of copies, lost replies and overdraft attempts, and the ledger still sums to 0 with no key posted twice.
| id | key | kind |
|---|---|---|
| 1 | open | deposit |
| 2 | t-1 | transfer |
| entry | account | amount |
|---|---|---|
| 1 | processor clearing | −$120.00 |
| 1 | bob | +$20.00 |
| 1 | alice | +$100.00 |
| 2 | alice | −$30.00 |
| 2 | bob | +$30.00 |
| name | balance | held |
|---|---|---|
| processor clearing | −$120.00 | $0.00 |
| alice | $70.00 | $0.00 |
| bob | $50.00 | $0.00 |
| merchant | $0.00 | $0.00 |
Highlighted rows are new or changed in this step.
Checks after this step
- ✓ every entry sums to $0.00
- ✓ 2 entries for 2 keys
- ✓ no customer account below $0.00
A retry, a copy and a lost reply all return the first entry. A crash mid-way leaves nothing behind, and the retry posts once.
hold(account, amount, key):
insert hold with key, skip if it exists
account.held += amount // CHECK: balance - held >= 01
capture(hold, to, amount, key): // amount <= hold.amount2
BEGIN
claim key; lock both accounts in id order
close the hold WHERE status = held // once only3
account.held -= hold.amount // the rest is free again4
post (account, -amount), (to, +amount)
COMMIT
release(hold):
close the hold; account.held -= hold.amount- 1The same no_overdraft CHECK covers holds, so a hold the account cannot cover fails.
- 2Capture can take less than the hold, for example a tip changed or an item was out of stock.
- 3The status check makes a second capture or release change nothing.
- 4The whole hold is lifted; only the captured amount moves.
Tested source Go: capture · SQL: holds
// Capture closes a hold and moves amount, at most the held amount, to another account. The rest
// of the hold returns to the available balance.
func (l *Ledger) Capture(ctx context.Context, holdID, to, amount int64, key, currency string) (Result, error) {
return l.run(ctx, NoFault, func(tx pgx.Tx) (Result, error) {
var from, held int64
if err := tx.QueryRow(ctx, q["read_hold"], holdID).Scan(&from, &held); err != nil {
return Result{}, fmt.Errorf("read hold %d: %w", holdID, err)
}
if amount <= 0 || amount > held {
return Result{}, fmt.Errorf("%w: %d of %d", ErrOverCapture, amount, held)
}
e := Entry{Key: key, Kind: "capture", Currency: currency, Legs: []Leg{{from, -amount}, {to, amount}}}
res, err := claim(ctx, tx, e)
if err != nil || res.Replayed {
return res, err
}
if err := l.lock(ctx, tx, e); err != nil {
return Result{}, err
}
if err := tx.QueryRow(ctx, q["close_hold"], holdID, "captured").Scan(&from, &held); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Result{}, fmt.Errorf("%w: hold %d", ErrHoldClosed, holdID)
}
return Result{}, fmt.Errorf("close hold %d: %w", holdID, err)
}
if _, err := tx.Exec(ctx, q["set_held"], from, -held); err != nil {
return Result{}, fmt.Errorf("lift hold on account %d: %w", from, err)
}
return res, apply(ctx, tx, res.EntryID, e)
})
}
INSERT INTO holds (account_id, amount, idempotency_key) VALUES ($1, $2, $3)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
-- Holds count against the balance: the same CHECK refuses a hold the account cannot cover.
UPDATE accounts SET held = held + $2 WHERE id = $1;
-- Capture or release a hold once. 0 rows: it was already closed.
UPDATE holds SET status = $2 WHERE id = $1 AND status = 'held'
RETURNING account_id, amount;A hold lowers the available balance without moving money. A capture closes the hold once and posts the amount actually charged.
| ref | ledger | processor | result |
|---|---|---|---|
| ch_1 | $50.00 | $50.00 | matched |
| ch_2 | $25.00 | $20.00 | amounts differ |
| ch_3 | $10.00 | missing at the processor | |
| ch_4 | $7.00 | missing in the ledger |
reconcile(day):
ledger = postings on the clearing account, by processor ref
processor = the processor's settlement report
match both sides on ref, keep rows from each side1:
only at the processor -> missing in the ledger2
only in the ledger -> missing at the processor
both, amounts differ -> amounts differ
both, amounts equal -> matched- 1A full outer join. An inner join would hide both kinds of missing row.
- 2A webhook was lost. Post the entry with the processor ref; the unique ref stops a second post.
Tested source SQL: reconcile
CREATE TEMP TABLE processor_report (
external_ref text PRIMARY KEY,
amount bigint NOT NULL
) ON COMMIT DROP;
-- Match the ledger's processor postings against the processor's report, both ways.
SELECT coalesce(l.external_ref, r.external_ref) AS ref,
l.amount AS ledger, r.amount AS processor,
CASE WHEN l.external_ref IS NULL THEN 'missing in the ledger'
WHEN r.external_ref IS NULL THEN 'missing at the processor'
WHEN l.amount <> r.amount THEN 'amounts differ'
ELSE 'matched' END AS result
FROM (SELECT j.external_ref, -p.amount AS amount
FROM journal_entries j JOIN postings p ON p.entry_id = j.id
WHERE p.account_id = $1 AND j.external_ref IS NOT NULL) l
FULL OUTER JOIN processor_report r ON r.external_ref = l.external_ref
ORDER BY 1;Every day I match my clearing account postings with the processor report, both ways, and open a case for each difference.
| copies come from | stopped by |
|---|---|
| Client retry after a timeout | Idempotency key |
| A double click, 2 tabs | Key made per action |
| Queue redelivery | Inbox, message id |
| Processor webhook resent | external_ref, unique |
I retry until I get a reply, and the server applies each key once. That gives an exactly-once effect.
| rule | why |
|---|---|
| Integer minor units in a bigint | Binary floats cannot hold 0.10 exactly. |
| A currency on every account and posting | The foreign key refuses a posting in the wrong currency. |
| Minor units per currency: USD 2, JPY 0, BHD 3 | From ISO 4217. Never assume 2. |
| Convert through FX accounts | One entry, 4 postings. Each currency sums to 0. |
| Round once, at a stated step | Post the remainder to a rounding account. |
I store integer minor units with a currency on every account, and I convert currencies through FX accounts.
| event | result | why it is safe | saved by |
|---|---|---|---|
| The reply is lost after the commit | The client retries. | The key returns the first entry. Nothing posts twice. | Unique key |
| Two copies arrive together | The second waits on the index. | It then finds the key and returns the first entry. | Unique key |
| The process dies mid-transaction | The connection ends. | Postgres rolls back. The retry posts once, with a new entry id. | Transaction |
| The same key, a different amount | The fingerprint differs. | Refuse the request. The first entry stays. | Fingerprint |
| A transfer exceeds the balance | The CHECK fails. | Nothing commits, and the key is free again. | CHECK |
| Opposite transfers at once | Both want the same 2 rows. | Id order: one waits, no deadlock. | Lock order |
| A bug writes one side of an entry | The sum is not 0. | The deferred trigger fails the commit. | Trigger |
| Someone edits a balance by hand | Cache and postings disagree. | The audit finds the drift. Postings are the truth. | Audit job |
| A processor webhook is lost | Charged, not in the ledger. | Reconciliation reports it; post it with the unique ref. | Reconcile |
| A webhook arrives twice | Same processor ref. | The unique external_ref refuses the second entry. | Unique ref |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One Postgres ledger, as on this sheet. | About 6,800 transfers a second spread over many accounts. 10 million a day is 116 a second. | Statements and history queries slow the primary; postings grow past memory. |
| 2 | Read replicas for statements, and monthly partitions of postings. | Reads leave the primary. Old months move to cheap storage. | One account takes most of the writes: lock waits on one row. |
| 3 | Split hot accounts into N sub-accounts. The balance is their sum. | About 480 a second on one row became 3,300 with 8. | The primary nears its write limit. |
| 4 | Shard by account id. A transfer across shards becomes a saga through clearing accounts. | Each shard adds its own rate. | Top of the ladder. |
Demand example, derived: 10,000,000 transfers ÷ 86,400 s ≈ 116 a second. Measured rates come from the lab, so read them as orders of magnitude.
One Postgres ledger carries thousands of transfers a second. I split hot accounts before I shard, and I shard by account id last.
0 of 10 known
Why store $0.10 as the integer 10 and not as a float?
What does double entry give you that a balance column does not?
The client sent a transfer and got a timeout. What should it do?
Two copies with the same key arrive at the same moment. What does Postgres do?
The same key arrives with a different amount. What do you return?
Why lock both accounts in id order?
Can one conditional UPDATE replace the explicit lock?
What does exactly-once mean for a payment?
A merchant account receives every payment and becomes the bottleneck. What do you do?
The processor settled a charge that the ledger never recorded. How do you find it, and what do you do?
- spread
- About 6,800 transfers a second over 1,000 accounts.
- hot
- About 480 a second when every transfer pays one account.
- split
- About 3,300 a second with that account split in 8.
- copies
- 3,973 copies of 2,000 requests: 0 keys posted twice, 0 deadlocks.
- demand
- 10 million transfers a day is 116 a second.
- bigint
- Up to about 92 quadrillion dollars in cents.
Postgres 16 on an 8-core laptop, 32 clients, 3 s per case, median of 3 runs. Other workloads shared the machine, so use these as orders of magnitude.