System Design
T9

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.

Not startedSaved in this browser only.
  1. 1Money is an integer count of minor units. Every movement is an entry whose postings sum to 0.
  2. 2Every money request carries an idempotency key, claimed in the same transaction as the postings.
  3. 3Lock the accounts in id order. Let a CHECK constraint refuse an overdraft.
  4. 4Exactly-once is an effect: at-least-once delivery plus idempotent processing.
T9
    A

    The double post

    a lost reply, then a retry
    Alice's appWallet servicebalancesPostgrespay bob $30.00move $30.00, commitpay bob $30.00 (retry)move $30.00, commit✕"ok" lost: the network drops itthe app times outalice −$30.00alice −$30.00 again✕ Alice paid $60.00 for one $30.00 transfer.
    • 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.

    B

    How to move money

    choices, and which are safe
    methodwherestatus
    UPDATE a balance column, no historyPostgresNot approved
    Amounts as floatsServiceNot approved
    Integer minor units in a bigintPostgresApproved
    Double-entry postings, sum 0 per entryPostgresApproved
    Balance as SUM(postings) on every readPostgresFew postings
    Cached balance, same transaction as postingsPostgresApproved
    Lock the payer, then the payeePostgresNot approved
    Lock both accounts in id orderPostgresApproved
    Conditional UPDATE WHERE balance >= amountPostgresRows in id order
    SERIALIZABLE, retry on 40001PostgresLow contention
    Idempotency key with a unique indexPostgresApproved
    Dedupe key in Redis onlyRedisNot 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.

    C

    Schema

    entities, then SQL
    n : 12+ per entry0 or more holdsaccountsPKidcurrencychar(3)allow_negativeboolbalancebigint, cachedheldbigintCHECK balance − held ≥ 0postingsPKentry_identryPKaccount_idaccountFKcurrencywith accountamountbigint, ≠ 0per entry: sum = 0journal_entriesPKidUQidempotency_keyfingerprintthe requestUQexternal_refprocessor idone row per money movementholdsPKidFKaccount_idamountbigint, > 0UQidempotency_keystatusheld, captured…counted in accounts.held
    every entry sums to 0sql
    -- 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();
    1. 1The check runs at commit, after every posting of the entry is in. An entry that does not sum to 0 fails the commit.
    2. 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.
    accounts, entries, postings, holdssql
    -- 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'))
    );
    1. 1Updated in the same transaction as the postings. An audit compares it with SUM(amount).
    2. 2The database refuses an overdraft, whatever the code does. Holds count against the balance.
    3. 3One entry per key. A retry or a copy finds the key and returns the first entry.
    4. 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.

    D

    The transfer path

    click a step; its path lights up
    Client appholds the keyLedger servicestateless, N copiesPostgresentries, postings, balancesCard processorsettlement report

    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.

    E

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresbigintExact integer cents. Sums never drift.Counters, quotas
    PostgresTransactionsThe key, the balances and the postings commit together, or nothing does.Every multi-row change
    PostgresCHECK constraintRefuses an overdraft below the holds, even after an application bug.Stock, seats
    PostgresDeferred constraint triggerChecks at commit that each entry sums to 0 per currency.Rules across rows
    PostgresComposite foreign keyA posting cannot use another currency than its account.Tenant checks
    PostgresUnique index and ON CONFLICT DO NOTHINGClaims the idempotency key. A copy waits on the index, then finds the key.Inbox tables, dedupe
    PostgresSELECT ... ORDER BY id FOR UPDATELocks every account of the entry in one fixed order: no deadlock.Bookings, inventory
    PostgresIdentity columnEntry ids. A rolled-back insert leaves a gap, so never read ids as a count.Surrogate keys
    PostgresCOPY into a temp table, FULL OUTER JOINLoads the processor report and matches it both ways.Imports, data checks
    ProcessorIdempotency keys; authorize, capture, void; settlement reportsThe external side of holds and reconciliation.Refunds, payouts
    RedisSET NX with a TTLLimit 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.

    F

    Post a transfer

    pseudo code
    one transactionpseudo code
    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
    1. 1Unique index and ON CONFLICT DO NOTHING. A copy that arrives at the same time waits here.
    2. 2The key is stored with a fingerprint of the request. A different amount under the same key is a client bug.
    3. 3Every transfer locks the lower account id first. Two opposite transfers cannot wait for each other.
    4. 4The UPDATE fails with a check violation. The transaction rolls back, and the key is free for a corrected request.
    5. 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
    Go: claim the key, apply the legsgo
    // 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
    }
    
    SQL: claim, lock, apply, postsql
    -- 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.

    G

    Lock order

    two opposite transfers
    ✕ Payer firstA pays BB pays Aacct 2acct 3holdsholdsboth waitA cycle: Postgres aborts one with 40P01.The lab reproduces it every run.✓ Account id orderA pays BB pays Aacct 2acct 3holdsholdswaits for acct 2Both lock acct 2 first.One commits; the other then runs.
    • 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.

    H

    See it: 2,000 transfers at once

    recorded from Postgres
    2,000 transfer requests from 32 workers became 3,973 copies1 to 3 copies at once; 95 lost the reply and retried; 184 asked for more than exists1,721posted95committed, reply lost1,787replayed: no change370refused: overdraftjournal entries1,8181,816 transfers + 2 deposits✓ 0entries that do not sum to 0✓ 0cached balance ≠ sum of postings✓ 0accounts below 0✓ 0sum of all postings✓ 0keys posted twice✓ 0deadlocksbalances at the end, equal to the sums the test derived from the requestsuser 1: $1,004.91user 2: $1,007.12user 3: $1,006.77user 4: $981.20each started at $1,000.00; no order of execution can overdraw any of them
    the auditpseudo code
    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
    1. 1Catches a cached balance that drifted, for example after manual SQL.
    Tested source SQL: the audit query
    SQL: the audit querysql
    -- 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.

    I

    See it: replay, duplicate, crash

    recorded from Postgres; ledger rows after each request
    journal_entries
    idkeykind
    1opendeposit
    2t-1transfer
    postings
    entryaccountamount
    1processor clearing−$120.00
    1bob+$20.00
    1alice+$100.00
    2alice−$30.00
    2bob+$30.00
    accounts
    namebalanceheld
    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.

    step 1 of 1

    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.

    J

    Holds and captures

    pseudo code
    authorize, capture, releasepseudo code
    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
    1. 1The same no_overdraft CHECK covers holds, so a hold the account cannot cover fails.
    2. 2Capture can take less than the hold, for example a tip changed or an item was out of stock.
    3. 3The status check makes a second capture or release change nothing.
    4. 4The whole hold is lifted; only the captured amount moves.
    Tested source Go: capture · SQL: holds
    Go: capturego
    // 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)
      })
    }
    
    SQL: holdssql
    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.

    K

    Reconciliation

    recorded against a sample report
    refledgerprocessorresult
    ch_1$50.00$50.00matched
    ch_2$25.00$20.00amounts differ
    ch_3$10.00missing at the processor
    ch_4$7.00missing in the ledger
    match both wayspseudo code
    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
    1. 1A full outer join. An inner join would hide both kinds of missing row.
    2. 2A webhook was lost. Post the entry with the processor ref; the unique ref stops a second post.
    Tested source SQL: reconcile
    SQL: reconcilesql
    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.

    L

    Exactly-once

    an effect, not a delivery
    At-least-oncedeliveryretry until a replycopies are possible+Idempotentprocessingclaim the key in theeffect's transaction=✓ Exactly-onceeffectmoney moves onceper key✕ At-most-once: never retryA lost reply leaves the result unknown. Money can be lost.✕ Dedupe in Redis, then post in PostgresTwo stores again. A crash between them loses the transfer or posts it twice.No network can deliver exactly once; only the receiver can apply once.
    copies come fromstopped by
    Client retry after a timeoutIdempotency key
    A double click, 2 tabsKey made per action
    Queue redeliveryInbox, message id
    Processor webhook resentexternal_ref, unique

    I retry until I get a reply, and the server applies each key once. That gives an exactly-once effect.

    M

    Money rules

    currency and rounding
    rulewhy
    Integer minor units in a bigintBinary floats cannot hold 0.10 exactly.
    A currency on every account and postingThe foreign key refuses a posting in the wrong currency.
    Minor units per currency: USD 2, JPY 0, BHD 3From ISO 4217. Never assume 2.
    Convert through FX accountsOne entry, 4 postings. Each currency sums to 0.
    Round once, at a stated stepPost the remainder to a rounding account.

    I store integer minor units with a currency on every account, and I convert currencies through FX accounts.

    N

    Failure cases

    what breaks, and why the money stays right
    eventresultwhy it is safesaved by
    The reply is lost after the commitThe client retries.The key returns the first entry. Nothing posts twice.Unique key
    Two copies arrive togetherThe second waits on the index.It then finds the key and returns the first entry.Unique key
    The process dies mid-transactionThe connection ends.Postgres rolls back. The retry posts once, with a new entry id.Transaction
    The same key, a different amountThe fingerprint differs.Refuse the request. The first entry stays.Fingerprint
    A transfer exceeds the balanceThe CHECK fails.Nothing commits, and the key is free again.CHECK
    Opposite transfers at onceBoth want the same 2 rows.Id order: one waits, no deadlock.Lock order
    A bug writes one side of an entryThe sum is not 0.The deferred trigger fails the commit.Trigger
    Someone edits a balance by handCache and postings disagree.The audit finds the drift. Postings are the truth.Audit job
    A processor webhook is lostCharged, not in the ledger.Reconciliation reports it; post it with the unique ref.Reconcile
    A webhook arrives twiceSame processor ref.The unique external_ref refuses the second entry.Unique ref
    O

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1One Postgres2+ replicas, partitions3+ split hot accounts4+ shards by accountmore load →
    Transfers a second, 32 clients101001k10k100kDemand, average: 116 transfers per secondDemand, average116Demand, 10× peak: 1,160 transfers per secondDemand, 10× peak1,160One hot payee: 478 transfers per secondOne hot payee478Hot payee split in 8: 3,264 transfers per secondHot payee split in 83,264Spread, 1,000 accounts: 6,846 transfers per secondSpread, 1,000 accounts6,846transfers per second, log scale
    One hot rowpayerpayerpayerpayermerchant478 transfers a second: one lockSplit into 8payerpayerpayerpayermerchant.1merchant.2merchant.3merchant.4merchant.5merchant.6merchant.7merchant.83,264 transfers a second: 8 locks
    stepaddit handlesmove up when you see
    1One 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.
    2Read 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.
    3Split 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.
    4Shard 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.

    P

    Drill

    predict, then reveal

    0 of 10 known

    1. Why store $0.10 as the integer 10 and not as a float?

    2. What does double entry give you that a balance column does not?

    3. The client sent a transfer and got a timeout. What should it do?

    4. Two copies with the same key arrive at the same moment. What does Postgres do?

    5. The same key arrives with a different amount. What do you return?

    6. Why lock both accounts in id order?

    7. Can one conditional UPDATE replace the explicit lock?

    8. What does exactly-once mean for a payment?

    9. A merchant account receives every payment and becomes the bottleneck. What do you do?

    10. The processor settled a charge that the ledger never recorded. How do you find it, and what do you do?

    Q

    Numbers to say

    measured in the lab
    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.