Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Intro to Accounting for Software People

Most engineers in fintech treat accounting as a business concern — someone else's problem, handed off to the Finance team. This is a mistake. Accounting is a constraint system. Its core invariant — sum(all_postings) = 0, always — is a conservation law, and violating it produces the same class of bugs as violating referential integrity or overflowing a counter.

This curriculum teaches accounting from first principles, the way you'd want a systems concept taught: with invariants, failure modes, and production consequences. It covers double-entry bookkeeping, account types, two-phase settlement, multi-currency inventory, P&L accounting, and the fintech-specific patterns that production ledgers require.

Every concept is shown in two representations simultaneously — Beancount (text-based, human-readable, a reasoning tool) and TigerBeetle (binary, OLTP-grade, a production tool). The same invariants hold at both levels. Seeing them side by side makes the abstractions collapse in the right way.

How to use this

Work linearly. Each module has:

  • A concept section — the core invariant or pattern
  • A Socratic dialogue — try to answer before expanding
  • Adversarial exercises — where the actual learning happens. Do not skip them.

Why Accounting Exists

Concept

Accounting is not record-keeping. It is a constraint system.

The central invariant: in a closed system, money is conserved. You cannot create it or destroy it — you can only move it between accounts. Double-entry bookkeeping is the mechanism that enforces this conservation as a machine-checkable property.

The engineer's framing: a ledger is a log of state transitions over a state machine where sum(all_postings_ever) = 0 at all times. Any system that violates this invariant has either produced value from nothing or destroyed it. Both are bugs.

Two systems, same invariant:

  • Beancount: enforces sum(postings) = 0 at parse time, in software
  • TigerBeetle: enforces the same invariant at the database level, at hardware speed, with strict serializability

Neither system has a concept of "balance" as stored state. Balance is always derived from the transaction history.


Socratic Dialogue

Q1: You have a Postgres table: transfers(id UUID, sender_id UUID, recipient_id UUID, amount_cents BIGINT). User balance = SELECT SUM(amount_cents) FROM transfers WHERE recipient_id = ? minus outflows. What's structurally wrong with this model for a payments company?

Answer

Single-entry bookkeeping. Each transfer row records a flow, but nothing enforces that what leaves one account arrives somewhere else. If a row is deleted, duplicated, or the sender_id is wrong, you cannot detect it — no conservation property is enforced. A credit that never debits anywhere is undetectable at the schema level.

Also: no audit trail for corrections (mutations destroy history), no structural prevention of negative balances, and computing "net balance" requires a full table scan rather than a read of a maintained accumulator.


Q2: If Alice sends Bob $100, how many rows are written to the database? Justify your answer.

Answer

At minimum two postings — one debit from Alice, one credit to Bob. Single-row = single-entry = no conservation. You need to record both sides of the movement.

In TigerBeetle: exactly one Transfer record (which contains both debit_account_id and credit_account_id). In Beancount: one Transaction with two Postings. Different representation, same two-sided structure.


Q3: Give a falsifiable definition of "a correct accounting system."

Answer

A correct accounting system is one where, at any point in time, sum(all_posted_amounts_across_all_accounts) = 0.

This is falsifiable: compute the sum. If it's non-zero, the system is incorrect. Period. No other definition is rigorous enough to be checkable.


Q4: Your payments DB crashes mid-write after debiting Alice's account but before crediting Bob's. What happened to the $100? How do you detect it? How do you fix it?

Answer

$100 has been destroyed. Alice's balance decreased; Bob's did not increase. The trial balance (sum(all_balances)) is now -$100 instead of $0 — detectable if you check. If you don't regularly verify the trial balance, you may not notice.

Fix: either roll back the debit (idempotent retry of the whole operation) or apply the credit. Prevention: atomic transactions — both postings commit together or neither does. This is exactly what TigerBeetle's linked events and two-phase transfers solve structurally.


Q5: Could you implement accounting in a spreadsheet? What breaks first at 10 users? At 10,000 concurrent transactions?

Answer

At 10 users: nothing technical breaks, but there's no enforcement. Any cell can be manually edited, destroying the audit trail. Invariants are not machine-checked.

At 10,000 concurrent transactions: serialization. A spreadsheet has no concept of ACID transactions. Two concurrent edits to the same cell produce a race condition. You lose the conservation invariant under any concurrency. Also: no structural prevention of invalid states — a formula can be deleted, a row accidentally omitted.


Q6: A bank statement shows your balance is $1,000. Your ledger says $1,050. Which is right?

Answer

There is no intrinsic answer. What matters is having a system with checkpoints (balance assertions) that flag the discrepancy and force you to find the cause. The bank's record might be wrong (missing a pending deposit). Your ledger might be wrong (a duplicate posting). You bisect until you find the divergent transaction. This is Module 5's subject.

The discipline of doing this systematically against external records is called reconciliation.


Exercises

Exercise 0-A: The broken single-entry log

Given this transaction log:

+100   Alice receives paycheck
 -30   Alice pays rent
 -50   Alice buys groceries   ← this row is lost in a crash
 +200  Alice receives bonus

(a) Compute Alice's balance with the missing row. (b) Compute it without. What is the discrepancy? (c) In a double-entry system, which invariant would catch this missing entry automatically, and at what point?

Solution

(a) With all rows: 100 - 30 - 50 + 200 = $220 (b) Without the grocery row: 100 - 30 + 200 = $270 — a $50 phantom balance. (c) The trial balance. In double-entry, the grocery purchase would have a matching posting to Expenses:Food for +$50. If the Alice debit posting is lost but the expense credit is not (or vice versa), the sum across all accounts is no longer zero. The system detects it on the next trial balance check.

In single-entry, you cannot detect this because there is no counterpart posting to go missing.


Exercise 0-B: Invariant audit

Here is a simplified Postgres schema for a payments startup:

CREATE TABLE transfers (
  id          UUID PRIMARY KEY,
  sender_id   UUID NOT NULL,
  receiver_id UUID NOT NULL,
  amount      BIGINT NOT NULL CHECK (amount > 0),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

List every accounting invariant this schema cannot enforce structurally. For each, name the class of bug it allows.

Solution
  1. Conservation across accounts: A row can be inserted with sender_id = receiver_id, debiting and crediting the same account (net zero but meaningless). Or sender_id can reference a non-existent account — value appears from nowhere.

  2. Immutability of history: A row can be UPDATEd or DELETEd, destroying the audit trail with no trace.

  3. No balance floor: There is nothing preventing sender_id's balance from going negative. The schema has no reference to account balances at all — balance is a derived computation that must be re-run at query time, not enforced at write time.

  4. No atomicity across legs: The schema is single-legged. There is no structural guarantee that a credit to receiver_id occurred. If you enforce this in application code, a crash between the two operations produces a half-applied transfer.

  5. No idempotency: Two rows with different ids but identical (sender_id, receiver_id, amount, created_at) are treated as distinct transfers. A network retry that generates a new UUID creates a duplicate charge.


Exercise 0-C: Conceptual mapping

Map each of the following software concepts to its accounting equivalent:

Software conceptAccounting equivalent
Unit test assertion?
Git commit?
Database transaction (ACID)?
Checksum / hash verification?
Append-only log?
Solution
Software conceptAccounting equivalent
Unit test assertionBalance assertion (balance directive in Beancount)
Git commitAn accounting transaction (atomic, dated, immutable once recorded)
Database transaction (ACID)Posting group (all postings commit atomically or none do)
Checksum / hash verificationTrial balance (sum of all balances = 0 is the checksum)
Append-only logThe ledger (history is append-only; corrections are new entries, never mutations)

Source reading for this module:

Double-Entry From First Principles

Concept

Everything in this module follows from one rule:

The sum of all postings in a transaction must equal zero.

Definitions:

  • Account: an accumulator. Starts at zero. Has a running balance = sum of all postings applied to it.
  • Posting: a (account, signed_amount) pair. A directed change to one account.
  • Transaction: a group of postings whose amounts sum to zero. The atomic unit of accounting work.
  • Balance: sum(all_postings_to_account) — always derived, never stored independently.
  • Trial balance: sum(balances_of_all_accounts) — always zero, by construction.

Beancount syntax:

2024-01-15 * "Alice sends Bob $100"
  Assets:Alice:Checking   -100.00 USD
  Assets:Bob:Checking      100.00 USD

Sum: -100 + 100 = 0. Invariant holds.

TigerBeetle equivalent:

Transfer {
  debit_account_id:  alice_account,   // Alice's balance decreases
  credit_account_id: bob_account,     // Bob's balance increases
  amount:            10000,           // $100.00 at scale 2 (cents)
  ledger:            1,               // USD ledger
}

These are dual representations of the same invariant. Beancount uses signed amounts on a flat list of postings. TigerBeetle uses a directed (debit_account, credit_account, positive_amount) triple. Both enforce sum = 0 — one structurally via the Transfer struct, one by validation at parse time.

Key distinction: the -100 in Beancount is a posting (a delta), not Alice's balance. If Alice started at $500, her balance is now $400.


Socratic Dialogue

Q1: The Beancount posting shows -100 USD on Alice's account. Is Alice's balance negative?

Answer

Not necessarily. The -100 is the posting — the change applied to the account. If Alice's running balance before this transaction was +500, it is now +400. Postings are deltas; balance is the running sum of all deltas. Never confuse a posting amount with an account balance.


Q2: A transaction has postings that sum to $0.01 due to floating-point rounding. Is this valid?

Answer

No. Beancount enforces balance within a configurable tolerance (default: very small), but an imbalance of $0.01 on a $1,000 transaction is not rounding error — it is a bug. Over billions of transactions, even $0.01 imbalances compound catastrophically.

This is why TigerBeetle uses u128 (unsigned 128-bit integer) for amounts — integer arithmetic is exact. The application chooses a scale factor (e.g., scale 2 = cents). There are no floating-point amounts anywhere in TigerBeetle's data model.


Q3: Can a transaction have only one posting?

Answer

In standard double-entry: no, because you cannot satisfy sum = 0 with a single non-zero posting. One posting of +100 sums to +100 ≠ 0.

In TigerBeetle: structurally impossible — a Transfer always has exactly one debit_account_id and one credit_account_id. You cannot submit a transfer with only one account.

In Beancount: you can have a zero-amount posting (trivially balances), but a single non-zero posting will fail validation.


Q4: Beancount allows you to omit the amount on one posting. How can that work without violating the invariant?

Answer

It is syntactic sugar. Beancount infers the missing amount as whatever value makes the sum zero. This is not a relaxation of the rule — it is a derivation from it. If two postings sum to +75, the inferred third posting is -75.

2024-01-15 * "Dinner"
  Liabilities:CreditCard   -47.23 USD
  Expenses:Restaurants              ; amount inferred as +47.23 USD

This is just convenience. The invariant still holds after inference.


Q5: If every transaction sums to zero, and all accounts start at zero, what is sum(all_account_balances) at any point in time?

Answer

Always zero. This is the trial balance invariant — a corollary of sum(postings_per_transaction) = 0 applied over all transactions ever.

Proof: each transaction contributes net zero to the total sum. Start at zero. Add transactions. The total never changes. sum(all_balances) = 0 always.

This is a powerful checksum. If you ever compute the trial balance and get non-zero, something is wrong: a posting was added without its counterpart, a record was mutated, or the data is corrupt.


Q6: TigerBeetle only supports one debit and one credit per Transfer. How do you record a paycheck that credits your checking account, with federal tax and social security split to two accounts?

Answer

You decompose it into multiple linked Transfers. A 4-posting Beancount transaction maps to (at minimum) 2 TigerBeetle Transfers with flags.linked = true. The flags.linked flag makes a batch of transfers atomic — they all succeed or all fail together.

This is Module 3's subject. The answer is not "you can't" — it is "you decompose and link."


Q7: What is the difference between a posting and a transaction?

Answer

A posting is a single (account, amount) pair — one side of a movement. It cannot exist on its own; it is always a child of a transaction.

A transaction is the atomic container that groups postings and enforces the zero-sum rule. A transaction has a date, description, and one or more postings. It is the unit of audit.

In TigerBeetle: a Transfer is a single atomic two-sided posting (debit + credit). There is no separate "transaction" type — multi-leg transactions are composed from linked Transfers.


Exercises

Exercise 1-A: Manual balance computation

Given these three Beancount transactions:

2024-01-01 * "Opening deposit"
  Equity:Opening        -1000.00 USD
  Assets:Checking        1000.00 USD

2024-01-05 * "Coffee"
  Assets:Checking           -5.00 USD
  Expenses:Food              5.00 USD

2024-01-10 * "Paycheck"
  Income:Salary          -3000.00 USD
  Assets:Checking         3000.00 USD

(a) Compute the balance of every account after all three transactions. (b) Verify the trial balance: do all balances sum to zero? (c) Change the paycheck to credit Checking by $3001. Show that the trial balance detects the imbalance.

Solution

(a)

  • Equity:Opening: -1000
  • Assets:Checking: +1000 - 5 + 3000 = +3995
  • Expenses:Food: +5
  • Income:Salary: -3000

(b) Sum: -1000 + 3995 + 5 + (-3000) = 0

(c) If Assets:Checking is credited $3001 instead of $3000:

  • Assets:Checking: +1000 - 5 + 3001 = +3996
  • Income:Salary: -3000
  • Sum: -1000 + 3996 + 5 + (-3000) = +1 ≠ 0

Beancount would reject this transaction with a balance error: the paycheck transaction itself has postings -3000 + 3001 = +1 ≠ 0. The error is caught at the transaction level before it even touches the trial balance.


Exercise 1-B: TigerBeetle transfer anatomy

The following Transfer is submitted:

Transfer {
  id:                0xABC123,
  debit_account_id:  0x01,   // Alice
  credit_account_id: 0x02,   // Bob
  amount:            10000,  // $100.00 at scale 2
  ledger:            1,
  code:              200,
}

(a) After this transfer posts, what is Alice's debits_posted? Bob's credits_posted? (b) Alice is an Asset account (debit-normal). How do you compute her balance from debits_posted and credits_posted? (c) What does TigerBeetle return if you set debit_account_id == credit_account_id? Why is this wrong conceptually, not just technically?

Solution

(a) Alice's debits_posted increases by 10000. Bob's credits_posted increases by 10000.

(b) For a debit-normal (Asset) account: balance = debits_posted - credits_posted. Alice's balance decreases when she's the debit account in a transfer — that's the correct direction for an asset outflow.

(c) TigerBeetle returns an error. Conceptually: a transfer from an account to itself creates a debit and credit on the same account that cancel out — net zero effect, no value moves anywhere. It is not a valid accounting entry; it is noise. More dangerous: if you believe you've moved funds but nothing actually changed, you have a silent bug in your logic.


Exercise 1-C: The phantom balance bug

A developer introduces a new operation: an "adjustment posting" that directly increments an account's balance without a corresponding counterpart posting anywhere. Describe:

(a) Which invariant this violates and why. (b) A concrete fintech example of the financial bug this creates. (c) How TigerBeetle's data model makes this class of bug structurally impossible.

Solution

(a) It violates sum(all_postings) = 0. A single non-zero posting with no counterpart contributes a non-zero amount to the trial balance, breaking the conservation invariant. Value is created from nothing.

(b) A user's balance account is adjusted +$50 with no corresponding debit to any source account. The user has $50 they didn't earn. The operator's books show $50 missing with no source. At scale, this is how accounting fraud works — unauthorized credit to a target account with no counterpart.

(c) TigerBeetle's Transfer struct requires both debit_account_id and credit_account_id to be different, valid, same-ledger accounts. There is no API to directly set an account's balance. Every balance change is a Transfer, which is always two-sided. You literally cannot submit a one-sided adjustment.


Source reading for this module:

Account Types and the Accounting Equation

Concept

The five account types are a semantic type system, not arbitrary taxonomy. They answer three questions about any account:

  1. Sign convention: does a positive balance mean you have something (+) or owe something (-)?
  2. Report membership: does this account belong on the balance sheet (point-in-time snapshot) or income statement (period flow)?
  3. Normal balance direction: is the account increased by a debit or a credit?
TypeNormal signReportIncreased by
Assets+Balance sheetDebit
LiabilitiesBalance sheetCredit
EquityBalance sheetCredit
Expenses+Income statementDebit
IncomeIncome statementCredit

The accounting equation follows directly from sum(all_postings) = 0:

A + L + E + X + I = 0

Where A = Assets, L = Liabilities, E = Equity, X = Expenses, I = Income — all as signed sums. This is not a separate axiom; it is a consequence of the double-entry invariant.

After clearing Income and Expenses into Equity (end-of-period):

A + L + E' = 0
→ Assets = Liabilities + Equity  (the familiar form, after sign flip)

TigerBeetle connection: TigerBeetle has no account_type field. The application encodes account semantics. The structural enforcement available:

  • flags.debits_must_not_exceed_credits — use on liability/equity/income accounts (credit-normal: credit balance must stay non-negative)
  • flags.credits_must_not_exceed_debits — use on asset/expense accounts (debit-normal: debit balance must stay non-negative)

The custodial wallet model (critical for fintech): when a user deposits money into your platform, you need two accounts:

  • Operator's bank account (Asset) — you hold the cash
  • User's balance account (Liability) — you owe it back to the user

The deposit increases both simultaneously. This is the structural reason fintech companies cannot just track "user balances" in a single table.


Socratic Dialogue

Q1: You owe a friend $200. Is "what you owe" a liability or an asset? What about from your friend's perspective?

Answer

From your perspective: Liability. It has a negative normal balance — it represents something you owe, not something you own.

From your friend's perspective: Asset. They have a claim on $200, which is something of value they possess.

Same real-world debt, opposite account types, depending on whose books you're keeping. This is a fundamental point: accounting is always from the perspective of one entity. There is no "objective" account type — it depends on who is recording.


Q2: Your credit card statement shows a $500 balance. Is that positive or negative in your Beancount books?

Answer

Negative in your books (Liabilities:CreditCard: -500 USD). You owe $500. The statement shows a positive number because the bank reports from their perspective — to them, it is an asset (money you owe them).

Beancount uses the owner's perspective consistently throughout. Liabilities, Equity, and Income accounts normally have negative balances.


Q3: Why does Beancount's accounting equation read A + L + E + X + I = 0 rather than Assets = Liabilities + Equity?

Answer

They are equivalent after sign adjustment. The traditional form Assets = Liabilities + Equity uses the convention that all balances are shown as positive numbers, with debit/credit rules applied separately per account type.

Beancount uses signed arithmetic throughout: Liabilities and Equity have negative normal balances. So A + L + E = 0 (after clearing) becomes A = -L + (-E) — and since L and E are negative when the company is solvent, -L and -E are positive numbers, yielding A = |L| + |E|, which is the traditional form.

Beancount's form is algebraically simpler: you never need to remember which accounts get their signs flipped.


Q4: What would happen to the accounting equation if you never cleared Income and Expenses into Equity?

Answer

The equation A + L + E + X + I = 0 still holds — it is always true by construction. But the balance sheet wouldn't "balance" in the traditional sense because it would only show A + L + E, which sums to NI = -(X + I) rather than zero.

Clearing (closing entries) moves the Income and Expense balances into Equity, making X + I = 0 and leaving only A + L + E = 0 — the clean balance sheet form. Without clearing, you'd need to show the Income Statement accounts on the balance sheet to make it balance, which is non-standard and confusing.


Q5: In TigerBeetle, an account has no type field. How does the application know if an account is an Asset or a Liability?

Answer

Convention, enforced by the application. TigerBeetle gives you debits_posted and credits_posted. Your application computes:

  • Asset: balance = debits_posted - credits_posted
  • Liability: balance = credits_posted - debits_posted

The semantic type lives in your application's metadata (your code field convention, your user_data fields, your external database). TigerBeetle enforces the structural invariant (balance floors via flags); your application enforces the semantic invariant (what the account represents).


Q6: A fintech company has a "float account" — money received from users that hasn't been deployed yet. Is it an Asset, Liability, or Equity?

Answer

Both — and this is the answer that separates engineers who understand accounting from those who don't.

From the operator's perspective:

  • The cash in the bank: Asset (you hold it)
  • The obligation to return it to users: Liability (you owe it)

A well-modeled system has both: the operator bank account (Asset) and a per-user liability (the user's balance). When a user deposits $100:

  • Assets:Bank +100 (you have $100 more)
  • Liabilities:UserBalance:Alice -100 (you owe Alice $100 more)

The sum is zero. This is the custodial wallet model and it is the foundational structure of every fintech platform that holds user funds.


Q7: Income:Salary normally has a negative balance. If you earn $3,000, should it show -3000 or +3000?

Answer

-3000 in Beancount's signed convention.

Why: Income represents something you gave away (your time, your work) in exchange for Assets. The $3,000 you received is Assets:Checking +3000. The work you gave away (which the company bought) is Income:Salary -3000. Sum: 0.

The negative sign is correct. "I gave away $3,000 of work." The asset received is positive; the income source is negative. This is consistent with the owner's-perspective convention: Income is the reason you have more assets, not an asset itself.


Exercises

Exercise 2-A: Classify the accounts

For a company like Midas (fintech brokerage), classify each from the operator's perspective:

AccountType (A/L/E/X/I)Normal balanceIncreased by
Cash in Midas's bank account
User's stock holdings (custodial)
Revenue from trading fees
AWS infrastructure costs
Pending user withdrawal requests
Regulatory fine (pending, not yet paid)
Solution
AccountTypeNormal balanceIncreased by
Cash in Midas's bank accountAsset+Debit
User's stock holdings (custodial)Asset+Debit (Midas holds shares as custodian)
Revenue from trading feesIncomeCredit
AWS infrastructure costsExpense+Debit
Pending user withdrawal requestsLiabilityCredit (Midas owes users these funds)
Regulatory fine (pending, not yet paid)LiabilityCredit (Midas owes the regulator)

Note on user stock holdings: from Midas's books, the shares held in custody are an Asset (they represent value Midas controls) with a corresponding Liability (the obligation to return them to users). Both sides exist simultaneously.


Exercise 2-B: The custodial wallet

A user deposits $100 into their Midas account via ACH.

(a) Write the complete Beancount entry. You need: Assets:Bank, Liabilities:Users:Alice. (b) Verify the postings sum to zero. (c) Alice then buys $100 of AAPL. Show the Beancount entries. What happens to the liability? (d) Why does the liability not disappear when Alice buys stock — she still has $100 of value with Midas?

Solution

(a) Deposit:

2024-03-01 * "Alice ACH deposit"
  Assets:Bank                    100.00 USD
  Liabilities:Users:Alice       -100.00 USD

Sum: +100 + (-100) = 0 ✓

(c) AAPL purchase ($100 at $175/share ≈ 0.571 shares — let's say exactly 1 share at $100 for simplicity):

2024-03-02 * "Alice buys AAPL"
  Assets:Custody:AAPL               1 AAPL {100.00 USD}
  Assets:Bank                  -100.00 USD

The Liabilities:Users:Alice account doesn't directly change here — that's a separate modeling choice. One approach: the liability is now denominated in AAPL rather than USD (the user "owns" 1 AAPL held in custody).

(d) The liability transforms in nature but doesn't disappear. Before: Midas owes Alice $100 cash. After: Midas owes Alice 1 AAPL. The obligation still exists. A more complete model would have Liabilities:Users:Alice:USD and Liabilities:Users:Alice:AAPL as separate accounts, the former decreasing and the latter increasing during the trade. The total liability in market-value terms remains $100 (ignoring price movement).


Exercise 2-C: TigerBeetle flag enforcement

A user's balance account is a Liability (credit-normal) with flags.debits_must_not_exceed_credits = true. Current state: credits_posted = 1000, debits_posted = 0.

(a) The user tries to withdraw $1,200 (a transfer that would debit the account by 1,200). What does TigerBeetle return? (b) The user deposits $500 (credit of 500). What is the available balance now? (c) What happens if you set both debits_must_not_exceed_credits AND credits_must_not_exceed_debits on the same account?

Solution

(a) TigerBeetle returns exceeds_credits (debit_would_exceed_credits error). The transfer is rejected. debits_posted + 1200 = 1200 > credits_posted = 1000 violates the flag.

(b) After deposit: credits_posted = 1500, debits_posted = 0. Balance = credits_posted - debits_posted = 1500. Available balance = 1500 (no pending amounts).

(c) This creates an account that can never have any transfers posted to it. debits_must_not_exceed_credits means debits ≤ credits. credits_must_not_exceed_debits means credits ≤ debits. The only state satisfying both simultaneously is debits_posted = credits_posted — a permanently zero-balance account where any non-zero transfer in either direction would violate one of the constraints. TigerBeetle will return an error when you try to create such an account.


Source reading for this module:


⚡ Interlude Challenge 1 (after Module 2)

Synthesis question: Your company processes $1B/day in transfers. The CFO asks for a real-time balance sheet. Your TigerBeetle cluster has strict serializability. Describe the query you'd run and its latency characteristics. What's the bottleneck? Would you use TigerBeetle directly for this or build a read model?

Discussion

The query: lookup_accounts or query_accounts on all balance-sheet accounts (Assets, Liabilities, Equity). For each, compute credits_posted - debits_posted or debits_posted - credits_posted based on account type.

Latency: TigerBeetle is optimized for write throughput (OLTP), not ad-hoc analytical reads (OLAP). Reading thousands of accounts with a single query is fine; computing a consolidated balance sheet across millions of accounts with aggregation is not what TigerBeetle is designed for.

The bottleneck: At $1B/day and average transaction size of $100, that's 10M transfers/day, ~115/second. TigerBeetle can handle this. The bottleneck for the balance sheet is aggregation across accounts — you'd want a read model (Postgres materialized view, or a CDC consumer that maintains running balances in a read database).

Real pattern: TigerBeetle is the system of record. A Change Data Capture (CDC) consumer reads from TigerBeetle's transfer log (/operating/cdc/) and maintains a separate OLAP database (e.g., ClickHouse, BigQuery) for reporting. The balance sheet runs against the OLAP store, not TigerBeetle directly.

Transactions, Postings, and the Transfer Primitive

Concept

A transaction carries: date, description (payee + narration), and N postings (N ≥ 2, sum = 0).

In Beancount, a transaction can have N postings. In TigerBeetle, a Transfer has exactly 1 debit account and 1 credit account. Multi-leg transactions are composed from linked Transfers.

TigerBeetle Transfer fields you need to know:

FieldTypePurpose
idu128Idempotency key. Client generates before submission. Never reuse.
debit_account_idu128Account to debit
credit_account_idu128Account to credit
amountu128Unsigned integer, application-defined scale
ledgeru32Currency/asset partition. Both accounts must be on same ledger.
codeu16Semantic event type (your enum: DEPOSIT=1, WITHDRAWAL=2, etc.)
user_data_128u128FK to your application DB (order ID, customer ID, etc.)
user_data_64u64Second timestamp or secondary reference
user_data_32u32Jurisdiction, locale, or other small reference
flagsu16linked, pending, post_pending_transfer, void_pending_transfer, etc.
timeoutu32For pending transfers: seconds until auto-expiry

The id field is critical: generate it on the client before any network call, persist it, and retry with the same id. TigerBeetle returns ok (newly created) or exists (already created — idempotent, treat as success). This is Module 9's subject but the pattern starts here.

The code field is your application's semantic event type. Define an enum:

DEPOSIT           = 1
WITHDRAWAL        = 2
TRADE_BUY         = 3
TRADE_SELL        = 4
DIVIDEND          = 5
FEE               = 6
CORRECTION        = 100

The user_data_128 field links TigerBeetle records to your application database. Without it, reconciliation requires a full join across two systems.

Multi-leg transactions via flags.linked: set flags.linked = true on all but the last Transfer in a batch. TigerBeetle processes them atomically — all succeed or all fail. The last Transfer in the chain has flags.linked = false.


Socratic Dialogue

Q1: A 4-posting Beancount transaction (e.g., paycheck with taxes) maps to how many TigerBeetle Transfers? What flag connects them?

Answer

Two Transfers, linked with flags.linked = true on the first, false on the second. Each Transfer is two-sided (one debit, one credit), so two Transfers cover four accounts. The flags.linked flag makes the pair atomic.

A 6-posting transaction would need 3 Transfers. The general rule: an N-posting balanced transaction decomposes into N/2 linked Transfers (assuming each posting is paired with exactly one other — more complex structures may need additional accounting accounts).


Q2: What is the code field in a TigerBeetle Transfer? What happens if you don't use it consistently?

Answer

code is a u16 (0–65535) that the application defines as a semantic event type. It is the machine-readable equivalent of a Beancount narration.

Without a consistent enum: you can query transfers by account but cannot filter by event type. "Show me all withdrawals" requires a full scan and application-side filtering rather than a query_transfers with code = 2. You lose queryability. Worse: corrections and reversals become indistinguishable from original operations in audit logs.

Define the enum early, document it, never reuse codes for different semantics.


Q3: A Transfer is immutable in TigerBeetle. You submitted one with the wrong amount. What do you do?

Answer

Submit a new correcting Transfer in the opposite direction for the difference (or a full reversal, then a correct Transfer). Link both to the original via user_data_128 = original_order_id. Use a distinct code value (e.g., CORRECTION = 100).

This is the append-only audit log property. The history is never modified; every correction is itself a dated, auditable event. You can always reconstruct "what was the net effect?" by summing all transfers with the same user_data_128.


Q4: Why is user_data_128 indexed in TigerBeetle? Couldn't you just look up the order in your application database?

Answer

Because reconciliation then requires a join across two systems with different consistency guarantees. If TigerBeetle has transfer_id → order_id queryable, you can audit entirely within TigerBeetle: "find all transfers related to order 999" without touching Postgres.

More importantly: TigerBeetle's consistency is stronger than Postgres's for financial records. If TigerBeetle says a transfer happened, it happened — even if your Postgres replica is lagged or your application DB was restored from backup. The user_data_128 link lets you bridge the two systems with TigerBeetle as the authoritative record.


Q5: What is the recommended ID generation strategy for TigerBeetle Transfers? Why not auto-increment or UUID v4?

Answer

TigerBeetle recommends a ULID-style 128-bit ID:

  • High 48 bits: millisecond timestamp
  • Low 80 bits: random

Benefits:

  • No central oracle needed (unlike auto-increment)
  • Lexicographically sortable by time (unlike UUID v4), which optimizes LSM tree performance — sorted inserts are faster than random
  • No collision risk (2^80 random bits per millisecond)
  • Client generates it, enabling the idempotency pattern

Random UUIDs (v4) are explicitly not recommended because they produce random write patterns in the LSM tree, significantly reducing throughput.


Q6: flags.linked is set on the last Transfer in a batch. What error does TigerBeetle return?

Answer

linked_event_chain_open — the chain has no terminator. TigerBeetle cannot determine where the atomic unit ends. This is semantically important: an open chain could be intentionally partial (a bug) or accidentally truncated (a network issue). TigerBeetle refuses to process an ambiguous chain rather than making a potentially incorrect assumption.

Rule: the last Transfer in a linked chain must have flags.linked = false. All others have flags.linked = true.


Exercises

Exercise 3-A: Decompose a multi-leg transaction

This Beancount transaction represents a paycheck:

2024-03-15 * "Employer" "March salary"
  Income:Salary              -5000.00 USD
  Assets:Checking             3800.00 USD
  Expenses:Taxes:Federal       900.00 USD
  Expenses:Taxes:Social        300.00 USD

(a) How many TigerBeetle Transfers are needed? Draw the debit/credit structure of each. (b) Write out the flags.linked values for each Transfer. (c) Transfer 2 fails (e.g., the federal tax account doesn't exist). What happens to Transfer 1? What does TigerBeetle return for the batch?

Solution

(a) Two Transfers:

T1: debit=Income:Salary, credit=Assets:Checking, amount=3800_00
T2: debit=Income:Salary, credit=Expenses:Taxes:Federal, amount=900_00

Wait — but this leaves Expenses:Taxes:Social unaccounted. We need three Transfers for four postings (the salary account appears three times as debit):

T1: debit=Employer:Payable, credit=Assets:Checking,          amount=3800_00  (flags.linked=true)
T2: debit=Employer:Payable, credit=Expenses:Taxes:Federal,   amount=900_00   (flags.linked=true)
T3: debit=Employer:Payable, credit=Expenses:Taxes:Social,    amount=300_00   (flags.linked=false)

Total debits from Employer: 5000. Total credits: 3800+900+300 = 5000. ✓

In practice, Income:Salary is a TigerBeetle account on the employer's side, acting as the source.

(b) T1: flags.linked=true, T2: flags.linked=true, T3: flags.linked=false

(c) If T2 fails: all three Transfers fail. The flags.linked chain is atomic. T1 is reversed, T3 never executes. The batch result contains individual error codes per Transfer — T2 shows the specific error, T1 and T3 show linked_event_failed. Alice's checking account and all tax accounts are unchanged.


Exercise 3-B: Design a code enum

You are building the TigerBeetle code enum for a brokerage. Define numeric values for: deposits, withdrawals, equity buy executions, equity sell executions, dividend credits, margin interest charges, wire transfer fees, and correcting entries.

Considerations: (a) What range would you reserve for correcting entries, and why? (b) What would a query look like to find all correcting entries in the last 24 hours?

Solution
// Business operations: 1-99
DEPOSIT              = 1
WITHDRAWAL           = 2
EQUITY_BUY           = 3
EQUITY_SELL          = 4
DIVIDEND             = 5
MARGIN_INTEREST      = 6
WIRE_FEE             = 7

// Corrections: 1000-1999 (separate range, easy to filter)
CORRECTION_REVERSAL  = 1000
CORRECTION_PARTIAL   = 1001
CORRECTION_WRITE_OFF = 1002

(a) A separate range (e.g., 1000+) makes corrections queryable and distinguishable from normal operations at a glance. If you use code=1 for both deposits and deposit corrections, you cannot distinguish them in TigerBeetle queries without also checking user_data. A dedicated range makes compliance reports trivial: "find all correcting entries" = query_transfers(code=1000..1999).

(b) query_transfers with code filter and timestamp range. TigerBeetle's query_transfers supports filtering by code, user_data_128, user_data_64, user_data_32, and account ID. The query would be something like: query_transfers({ account_id: ..., code: 1000 }) with pagination over the timestamp range.


Exercise 3-C: The ID timing trap

A developer writes this payment submission code:

def submit_payment(from_account, to_account, amount):
    response = api.create_transfer(
        debit_account_id=from_account,
        credit_account_id=to_account,
        amount=amount,
        id=generate_ulid()  # ID generated inside the API call
    )
    return response

The network times out. The developer retries by calling submit_payment again.

(a) What is the bug? (b) What is the financial risk? (c) Rewrite the function correctly.

Solution

(a) A new ULID is generated on each call. The retry creates a second Transfer with a different id. TigerBeetle has no way to know these are the same logical operation — it sees two distinct Transfers and creates both.

(b) The user is double-charged. Both transfers post to the accounts. The first transfer may or may not have succeeded (we don't know — the network timed out). If it did succeed, the retry creates a duplicate.

(c) Correct implementation:

def submit_payment(from_account, to_account, amount, idempotency_key=None):
    # Generate and persist BEFORE any network call
    if idempotency_key is None:
        idempotency_key = generate_ulid()

    store.save('pending_transfer_id', idempotency_key)  # durable storage

    response = api.create_transfer(
        id=idempotency_key,
        debit_account_id=from_account,
        credit_account_id=to_account,
        amount=amount,
    )

    if response.result in ('ok', 'exists'):
        store.delete('pending_transfer_id')
        return 'success'
    else:
        # balance error, invalid account, etc. — don't retry
        store.delete('pending_transfer_id')
        return 'error', response.result

On retry: pass the same idempotency_key. TigerBeetle returns exists. Treat it as success.


Source reading for this module:

TigerBeetle's Data Model

Concept

TigerBeetle has exactly two entity types: Account and Transfer. Everything — balances, audit trails, rate limits, authorization holds, settlement — is expressed through these two types plus the ledger concept.

Account fields:

FieldTypePurpose
idu128Unique identifier
debits_pendingu128Sum of pending debit amounts (reserved, not yet posted)
debits_postedu128Sum of posted debit amounts
credits_pendingu128Sum of pending credit amounts
credits_postedu128Sum of posted credit amounts
user_data_128u128Application reference (e.g., customer ID)
user_data_64u64Secondary reference
user_data_32u32Tertiary reference
ledgeru32Currency/asset partition
codeu16Account type in your application's semantics
flagsu16Balance invariant flags

Balance arithmetic:

// Debit-normal account (Asset, Expense):
posted_balance    = debits_posted   - credits_posted
available_balance = posted_balance  - debits_pending

// Credit-normal account (Liability, Equity, Income):
posted_balance    = credits_posted  - debits_posted
available_balance = posted_balance  - credits_pending

The pending fields represent reserved funds — committed but not yet settled. A user's spendable balance is posted - pending, not just posted.

Why separate debits_posted and credits_posted instead of a net balance? Two reasons:

  1. Auditability: a negative credit (credits decreasing below zero) is impossible if everything is correct. Separate counters let you detect anomalies a net balance cannot.
  2. Correctness: flags.debits_must_not_exceed_credits enforces debits_posted + debits_pending ≤ credits_posted — this check is only possible with separate accumulators.

Ledger = currency partition. Accounts on different ledgers cannot transact directly. A Transfer with mismatched ledgers returns accounts_must_have_the_same_ledger. Cross-currency = two linked Transfers via liquidity accounts (Module 7).

Account immutability. Like Transfers, Accounts cannot be deleted. To "close" an account, you drain its balance to zero (submit Transfers to move all funds out), then mark it as closed in your application layer. TigerBeetle has a close-account recipe for this.


Socratic Dialogue

Q1: An account has debits_posted = 500, credits_posted = 300. What is the balance? Is it positive or negative? What account type is it?

Answer

Cannot fully answer without knowing the account type (which TigerBeetle doesn't store — your application knows it).

If Asset (debit-normal): balance = 500 - 300 = +200. Positive, healthy — the account has $200.

If Liability (credit-normal): balance = 300 - 500 = -200. Negative, which means debits exceed credits on a liability account — unusual, possibly a bug (unless this is an overpaid liability that has gone negative intentionally).

This ambiguity is intentional. TigerBeetle is a generic financial primitive. Your application assigns semantic meaning.


Q2: What is debits_pending? Give a concrete fintech scenario where it matters.

Answer

debits_pending is the sum of all pending (phase-1) Transfer amounts where this account is the debit side. These amounts are reserved but not yet settled.

Concrete scenario: a user initiates a $500 withdrawal via ACH. Before ACH settles (1-3 days), TigerBeetle creates a pending Transfer that increases debits_pending by 500. The user's available balance immediately reflects the hold:

available = credits_posted - debits_posted - debits_pending
          = 1000           - 0             - 500
          = 500  (not 1000)

If debits_must_not_exceed_credits is set, a second $600 withdrawal attempt fails immediately — TigerBeetle checks against available balance. This prevents over-withdrawal during the settlement window.


Q3: Why does TigerBeetle store debits_posted and credits_posted separately rather than a single signed net balance?

Answer

Two reasons:

  1. Anomaly detection: a signed net balance of $0 could mean "nothing has happened" or "equal debits and credits." With separate accumulators, you can see if unusual patterns occurred (e.g., unexpectedly high gross flow through an account that nets to zero).

  2. Invariant enforcement: flags.debits_must_not_exceed_credits checks debits_posted + debits_pending ≤ credits_posted. You need separate values to perform this check. A single signed integer cannot express "debits cannot exceed credits" as a structural constraint — you'd need application-level checks, which are race-prone.


Q4: A Transfer has ledger = 1 but the debit account has ledger = 2. What happens?

Answer

TigerBeetle returns accounts_must_have_the_same_ledger error. Additionally, the Transfer's ledger field must match both accounts' ledger fields.

Conceptually: you cannot transact across currencies without an explicit conversion. Setting mismatched ledgers is the equivalent of trying to add USD and EUR directly — it is a type error. Cross-currency transfers require two linked Transfers via liquidity accounts (Module 7), each on their respective ledger.


Q5: You want to track a user's USD and EUR balances. One TigerBeetle account or two?

Answer

Two accounts — one on ledger=USD, one on ledger=EUR.

TigerBeetle accounts are single-commodity by design. This is a divergence from Beancount, where a single account can hold an Inventory of multiple commodities (e.g., Assets:Brokerage can hold USD, AAPL, and MSFT simultaneously).

In TigerBeetle, the ledger field is the commodity partition. Multi-commodity = multiple accounts. The application tracks which accounts belong to which user.


Q6: Can you delete a TigerBeetle Account? What do you do when a user closes their Midas account?

Answer

No. Accounts (like Transfers) are immutable and cannot be deleted.

Closing procedure:

  1. Submit Transfers to drain all non-zero balances to zero (e.g., refund cash to the user's bank, liquidate positions).
  2. Mark the account as "closed" in your application database (Postgres, etc.) — TigerBeetle has no concept of account status.
  3. Optionally: TigerBeetle has a close-account recipe that uses the flags.closed account flag to reject future Transfers against that account.

The historical transfers remain queryable forever — this is the audit trail. "Closed" means "no new activity allowed," not "erased."


Q7: flags.linked is set on the last Transfer in a batch. What error? Why does this matter?

Answer

linked_event_chain_open. TigerBeetle cannot process a chain without a clear terminator — it doesn't know where the atomic unit ends.

This matters because a partial chain that silently executes would be catastrophic: Transfer 1 debits Alice, Transfer 2 credits Bob, Transfer 3 was supposed to credit the fee account but was cut off. If T1 and T2 process but T3 doesn't, the fee is silently lost. TigerBeetle's error prevents this class of bug at the protocol level.


Exercises

Exercise 4-A: Schema design

Design TigerBeetle accounts for a crypto exchange supporting USD and BTC:

AccountLedgerAccount typeTigerBeetle flags
User's USD balance (Liability — exchange owes user)
User's BTC balance (Liability — exchange owes user)
Exchange's USD operating account (Asset)
Exchange's BTC cold storage (Asset)
Trading fee collection (Income)
Market maker USD liquidity pool

For each: specify ledger (e.g., USD=1, BTC=2), the code convention (e.g., 1=asset, 2=liability, 3=income), and which balance flag to set.

Solution
AccountLedgerCodeFlag
User's USD balance (Liability)1 (USD)2debits_must_not_exceed_credits — prevents negative USD balance (user can't withdraw more than they deposited)
User's BTC balance (Liability)2 (BTC)2debits_must_not_exceed_credits — prevents negative BTC balance
Exchange's USD operating account (Asset)1 (USD)1credits_must_not_exceed_debits — asset balance should stay non-negative
Exchange's BTC cold storage (Asset)2 (BTC)1credits_must_not_exceed_debits
Trading fee collection (Income)1 (USD)3debits_must_not_exceed_credits — fees accumulate as credits
Market maker USD liquidity pool1 (USD)1credits_must_not_exceed_debits — must stay non-negative

The code values (1=Asset, 2=Liability, 3=Income) are application-defined conventions stored in TigerBeetle. Your application reads code to determine how to compute the balance direction.


Exercise 4-B: Balance arithmetic

An account has:

debits_pending  = 200
debits_posted   = 1500
credits_pending = 0
credits_posted  = 2000

This is a user's USD balance account (Liability, credit-normal) with flags.debits_must_not_exceed_credits = true.

(a) What is the posted balance? (b) What is the available balance (accounting for pending)? (c) A withdrawal of $350 is requested (pending transfer of 350). Does TigerBeetle allow it? (d) What if the flag is NOT set — does TigerBeetle allow the $350 pending transfer?

Solution

(a) Posted balance = credits_posted - debits_posted = 2000 - 1500 = 500

(b) Available balance = posted_balance - debits_pending = 500 - 200 = 300

(c) With debits_must_not_exceed_credits: TigerBeetle checks debits_pending + new_pending + debits_posted ≤ credits_posted200 + 350 + 1500 = 2050 > 2000. Rejected. The pending transfer fails with exceeds_credits.

(d) Without the flag: TigerBeetle does not enforce the balance floor. debits_pending would become 550. debits_posted could later become 1850, resulting in a negative effective balance of 2000 - 1850 = 150... wait, that's still positive. Let's check: after posting the 350 pending transfer, debits_posted = 1850, credits_posted = 2000, posted balance = 150. Still positive in this case. The flag matters when debits_pending + debits_posted would exceed credits_posted — which would happen here if we also tried to post the existing pending 200 first: 200 + 1500 = 1700 ≤ 2000 ✓, then 350 + 1500 = 1850 ≤ 2000 ✓. So both would actually succeed without the flag. But the combined pending check fails with the flag — the flag uses pessimistic accounting (reserves are counted against available balance).


Exercise 4-C: The one-account-for-everything trap

A developer creates a single TigerBeetle Account per user to hold all their assets (USD, AAPL shares, BTC), using user_data_32 to encode the asset type in each Transfer. Every balance query scans all transfers and filters by user_data_32.

Name three specific ways this breaks compared to the correct design (separate accounts per asset/ledger):

Solution
  1. Cross-ledger mixing: TigerBeetle's amount is a u128 integer. 10000 in USD means $100. 10000 in BTC means 0.0001 BTC. If both are stored in the same account on the same ledger, the amounts are added together numerically — you lose all unit information. You cannot distinguish "$100 + 0.0001 BTC = $100.01" from "200 satoshis" because the numbers have been summed into a meaningless total.

  2. No structural balance enforcement: the flags.debits_must_not_exceed_credits flag applies to the entire account. You cannot enforce a USD floor without also enforcing it on BTC. You cannot have separate balance floors per asset type. All structural invariants apply to the aggregate, which is meaningless.

  3. Query performance and auditability: to compute a user's USD balance, you must scan and filter ALL transfers for that user, not just USD transfers. This is O(all_transfers) instead of O(1) via lookup_accounts. At scale, this is completely impractical. Also, TigerBeetle's query_transfers is indexed by account — a single account accumulates all asset types, making targeted queries impossible without full scans.


Source reading for this module:


⚡ Interlude Challenge 2 (after Module 4)

Synthesis question: A senior engineer proposes replacing TigerBeetle with Postgres and implementing double-entry via CHECK constraints and triggers. What can Postgres enforce that TigerBeetle cannot? What can TigerBeetle enforce that Postgres cannot? Which would you choose at 1 million transfers per second, and why?

Discussion

What Postgres can enforce that TigerBeetle cannot:

  • Complex cross-account SQL constraints (e.g., total user portfolio value > X)
  • Foreign key relationships to application tables
  • Custom validation logic in triggers (arbitrary code)
  • Multi-table atomicity without linked events

What TigerBeetle can enforce that Postgres cannot (at OLTP scale):

  • Strict serializability at 1M+ TPS via single-core deterministic state machine — Postgres cannot match this throughput with correct serializable isolation
  • Hardware-level durability with explicit I/O control (direct storage, no OS buffering, no write-behind cache)
  • Built-in pending/posting two-phase transfer state machine
  • Immutable append-only records by design (Postgres rows can be UPDATEd — you need extra application code to prevent mutations)
  • Byzantine fault tolerance across cluster nodes

At 1M TPS: TigerBeetle. Postgres with SERIALIZABLE isolation at 1M TPS on financial records is not practically achievable without sharding, which introduces distributed transaction complexity. TigerBeetle is purpose-built for this workload. Use Postgres as the application database for user profiles, orders, etc. — and TigerBeetle as the financial ledger.

Consistency, Assertions, and Balance Invariants

Concept

Two strategies for enforcing correctness — and they are not substitutes, they are complements:

  • Detective enforcement (Beancount): check after the fact. A balance directive is a spot-check against an external ground truth (your bank statement). If it fails, something went wrong between the last check and now.
  • Preventive enforcement (TigerBeetle flags): reject invalid state at write time, before it ever lands. No post-hoc checking needed because the invalid state cannot exist.

Beancount's balance directive:

2024-03-01 balance Assets:Checking  1250.00 USD

This asserts: "at the beginning of 2024-03-01, Assets:Checking held exactly 1250.00 USD." If Beancount computes a different value from the transaction history, it raises an error. The directive is dated — it applies at a point in time, not at a file position. It is order-independent. You can have as many as you want; each one narrows the bisection window for finding errors.

The key property: balance assertions are checkpoints against external reality. They tie the ledger to the physical world (bank records, brokerage statements). Without them, your ledger can drift from reality undetected.

Bisection process for discrepancies:

If your balance assertion fails (or your bank statement doesn't match):

  1. Find the earliest date where ledger and bank agree.
  2. Find the latest date where they disagree.
  3. Bisect: add a balance assertion at the midpoint and check if it passes.
  4. Recurse into the failing half. Narrow until you isolate the divergent transaction.

The denser your balance assertions, the shorter each bisection.

TigerBeetle's preventive model:

TigerBeetle never stores balances. debits_posted, credits_posted, debits_pending, credits_pending are accumulators derived from the transfer log. A "balance" is a live derivation — read it by calling lookup_accounts, which reads the accumulators.

This means: you cannot set a balance. You cannot correct a balance. You can only submit new Transfers. A correction is always a new Transfer with the appropriate direction and code = CORRECTION, linked to the original via user_data_128.

Balance-invariant transfers (the control account pattern):

Sometimes you want balance enforcement for only a subset of transfers, not all of them. The recipe: use a control account with the opposite flag. The control account never holds funds permanently — it participates in a linked chain that enforces the invariant atomically, then unwinds:

T1: Source → Destination   amount=transfer  flags.linked
T2: Destination → Control  amount=1         flags.linked | pending | balancing_debit
T3: void T2                                 flags.void_pending_transfer

If T2's balancing_debit finds that Destination.credits > Destination.debits, the chain passes. If Destination.debits would exceed credits, T2 triggers exceeds_debits on the control account and the whole chain (including T1) rolls back atomically. No funds moved. No partial state.


Socratic Dialogue

Q1: You run a balance assertion in Beancount and it fails. What does that tell you? What doesn't it tell you?

Answer

It tells you that the computed balance of the account on that date does not match the asserted amount. What went wrong is not specified — it could be: a missing transaction, a duplicate posting, a wrong amount on an existing transaction, a transaction on the wrong date, or a data entry typo.

What it doesn't tell you: when the error was introduced. The assertion date only establishes "the error occurred somewhere before this date." You need bisection (more assertions at earlier dates) to isolate the cause.


Q2: Beancount balance assertions are "date-based," not "file-order-based." Why does this matter?

Answer

Date-based means the assertion checks the computed balance at the start of that calendar date, regardless of where the assertion appears in the file. This makes assertions order-independent — you can move them around in the file without changing their semantics.

File-order assertions (Ledger's approach) check the running balance at the point in the file where the assertion appears. Moving the assertion above or below a transaction changes whether it passes or fails. This is fragile: the same set of transactions can pass or fail depending on how the file is sorted.

Date-based is harder to use for intra-day disambiguation (two transactions on the same date cannot be separated by a date-based assertion), but it's much easier to reason about correctness.


Q3: In TigerBeetle, you discover that an account's balance is wrong. You cannot run UPDATE accounts SET debits_posted = X. What are your options?

Answer

Your only option is new Transfers. If debits are too high (the account shows less value than it should), submit a correcting Transfer that credits the account for the difference. If credits are too high, submit one that debits.

Convention:

  1. Use code = CORRECTION (or whatever your enum value is for corrections)
  2. Set user_data_128 to link back to the original erroneous Transfer's order ID
  3. Document the correcting Transfer in your application's audit log

The historical record is never mutated. The correction is itself an auditable event with a timestamp. Reconstructing "what happened" is always possible: sum all Transfers (including corrections) for the account.

This is identical to how real accounting works: you never alter past entries, you post adjusting entries.


Q4: A bank statement shows $1,300. Your Beancount ledger shows $1,250. You have balance assertions at the start of each month. It's now April 5. The March 1 assertion passed. How do you find the discrepancy?

Answer

The discrepancy occurred between March 1 and April 5. Bisect:

  1. Add a balance assertion at March 16. If it fails, the error is March 1–16. If it passes, the error is March 16–April 5.
  2. Repeat: add an assertion at the midpoint of the failing range.
  3. Continue until you isolate a single transaction or day.

At that point, compare that transaction against the bank statement to find the discrepancy (missing transaction, wrong amount, date mismatch).

This is why dense balance assertions have compounding value — not just as checkpoints, but as instruments that reduce bisection depth from O(n) to O(log n) over transaction count.


Q5: flags.debits_must_not_exceed_credits is set on an account. A pending transfer is submitted that would bring debits_pending + debits_posted above credits_posted. What does TigerBeetle do?

Answer

TigerBeetle rejects the pending transfer immediately with exceeds_credits. It does not wait until the transfer posts.

This is the "pessimistic" model: pending amounts are counted against the available balance immediately when the pending transfer is created. The rationale is that a pending transfer is a firm commitment — TigerBeetle guarantees that when the pending transfer eventually posts, it will not violate the balance invariant. The only way to guarantee this is to reserve the funds at pending time.

Consequence: you cannot create a pending transfer "speculatively" and hope it will be within limits by the time it posts. The limit is checked upfront.


Q6: The control account pattern for balance-invariant transfers uses a balancing_debit flag. What does that flag do, exactly?

Answer

flags.balancing_debit is a special modifier on a pending transfer that sets the transfer amount to the net credit balance of the debit account at the time of processing. In other words: instead of specifying a fixed amount, TigerBeetle computes max(0, credits_posted - debits_posted) on the debit account and uses that as the pending amount.

This is how the balance check works: if the destination account has a credit balance of X, the pending balancing_debit creates a pending debit of X on the destination and a pending credit of X on the control account. If X would cause the control account to violate credits_must_not_exceed_debits, the transfer — and the entire linked chain — fails.

The pending transfer is immediately voided (T3 in the recipe), so no funds actually move. The mechanism is purely an atomic balance probe.


Q7: A developer argues: "We verify the trial balance at the end of each day via a batch job, so we don't need TigerBeetle's structural enforcement." What's wrong with this argument?

Answer

Several things:

  1. Window of invalidity: between the invalid state occurring and the batch job running, the system operated on incorrect data. If a user was shown a wrong balance and acted on it (a second withdrawal, a trade), the damage is already done.

  2. At-scale detection latency: at high throughput, a $0.01 invariant violation per 1,000 transactions is financially material within hours. A daily batch check catches it only after 24 hours of compounding.

  3. Race conditions in the batch job itself: computing the trial balance requires reading all accounts consistently. If transfers continue posting during the batch scan, the scan is not a consistent snapshot. You need either a full lock or a snapshot isolation — both expensive.

  4. Structural enforcement is zero-cost at runtime: TigerBeetle's flag checks happen inside the already-running state machine. There is no additional overhead compared to no flags. The "batch job" approach has cost; the flag approach is essentially free.

Detective and preventive controls are complements. The batch job is the detective fallback; the flags are the primary prevention.


Exercises

Exercise 5-A: Bisection in practice

Your ledger has monthly balance assertions. The March 1 assertion passed ($4,200). The April 1 assertion fails — your ledger says $3,950, the bank says $4,100. March had 31 transactions.

(a) What is the maximum number of bisection steps to isolate the error, assuming you can add a balance assertion for any date? (b) You add a March 16 assertion. The ledger computes $4,050; the bank statement shows $4,050 for that date. What does this tell you, and where do you focus next? (c) What would make bisection impossible in a single-entry system?

Solution

(a) ⌈log₂(31)⌉ = 5 steps. After 5 bisections you have narrowed to a single day (or transaction) among 31.

(b) The March 16 assertion passes (both agree at $4,050). The error is therefore in the March 16–31 window. Focus on the second half of March: add a March 24 assertion and repeat.

(c) In a single-entry system there is no trial balance invariant and no conservation property. "Correct" has no machine-checkable definition. You could not define what a balance assertion means because there is no counterpart posting to confirm. Any amount of missing, duplicated, or modified entries could produce a plausible balance. You would need to manually compare every transaction against an external record — no bisection is possible because there is no local invariant to test.


Exercise 5-B: Control account mechanics

You have a user balance account (Destination) with no balance flags set. You want to enforce that after a deposit, the user's balance does not exceed $10,000 (i.e., credits_posted - debits_posted ≤ 10,000). You implement the balance-invariant transfer pattern.

(a) Which flag goes on the control account? (b) The user currently has $9,800. A $500 deposit is attempted. Walk through whether T2 (the balancing_debit pending transfer) succeeds or fails. (c) If T2 fails, what is the state of the Destination account after the batch?

Solution

(a) The control account gets flags.credits_must_not_exceed_debits. This is the opposite of what we're enforcing on the destination (which has credit-normal balance). The control account's debit-normal flag is what creates the tripwire.

(b) After the $500 deposit posts (T1), Destination.credits_posted = 10,300. The balancing_debit pending transfer on T2 computes the destination's net credit balance: 10,300 - debits_posted. If debits_posted = 0, that's 10,300. T2 tries to create a pending credit of 10,300 on the control account. The control account has credits_must_not_exceed_debits and zero balance — 10,300 credits would immediately exceed its 0 debits. T2 fails with exceeds_debits.

(c) Because T1 and T2 are linked (flags.linked), T2's failure cascades: T1 is rolled back. The Destination account is unchanged — no deposit occurred. The entire batch fails atomically. The user's balance remains $9,800.


Exercise 5-C: Correction without mutation

A developer submitted a Transfer that charged a user $50 in fees instead of $5 (a 10x error). The transfer has already posted. The user's account shows the wrong balance.

(a) Write out the sequence of TigerBeetle Transfers needed to correct this. Include code, user_data_128, and direction for each. (b) After the correction, what does a query_transfers for this user's account show? How does an auditor reconstruct the correct final balance? (c) Why is this approach superior to a direct balance mutation, from a regulatory standpoint?

Solution

(a) Two Transfers:

T_reversal:
  debit_account_id:  fee_collection_account
  credit_account_id: user_account
  amount:            50_00  (the original wrong amount)
  code:              CORRECTION_REVERSAL (e.g., 1000)
  user_data_128:     original_order_id

T_correct:
  debit_account_id:  user_account
  credit_account_id: fee_collection_account
  amount:            5_00   (the correct amount)
  code:              FEE (e.g., 7)
  user_data_128:     original_order_id

These can be submitted as a linked pair to ensure they both apply or neither does.

(b) query_transfers shows three entries: the original wrong fee (+$50 debit), the reversal (-$50 correction credit), and the correct fee (+$5 debit). Net effect on the user account: -$5. An auditor reconstructs the final balance by summing all Transfer amounts — the three entries net to -$5, which is the correct fee. The $50 error is visible in history, as is its correction, with timestamps and user_data_128 linking all three to the original order.

(c) Regulatory reporting requires an immutable audit trail. If you directly mutate the balance, the error disappears. Regulators (and auditors) cannot see that an error occurred, when it was detected, or how it was resolved. The correction-as-Transfer approach preserves all of this: the error, the correction, the operator who submitted it, and the timestamp — all are permanent record. In regulated industries (banking, brokerage), this is not optional.


Source reading for this module:

Two-Phase Transfers and Settlement

Concept

Every real financial system separates authorization from settlement. When you swipe your credit card, the merchant gets authorization instantly — but funds settle days later. Your bank's available balance drops immediately; the posted balance changes only when settlement clears.

TigerBeetle models this natively with two-phase transfers.

Phase 1 — Reserve (Pending):

flags.pending = true

Effect on debit account:   debits_pending  += amount
Effect on credit account:  credits_pending += amount

debits_posted and credits_posted are UNCHANGED.

The funds are reserved. Available balance decreases. Posted balance is unchanged.

Phase 2 — Resolve:

Three outcomes, each a new Transfer with pending_id pointing to the original:

flags.post_pending_transfer:
  debits_pending  -= amount_posted
  debits_posted   += amount_posted
  (amount_posted ≤ pending amount; remainder auto-released)

flags.void_pending_transfer:
  debits_pending  -= amount
  (full reservation released, nothing posts)

Expiry (timeout elapsed, no action taken):
  debits_pending  -= amount
  (same effect as void, automatic)

Key points:

  • Transfers are immutable. The pending Transfer is never modified. The post/void is a new Transfer with its own id, timestamped separately.
  • Partial posting: you can post less than the pending amount. amount_posted < pending_amount means only amount_posted settles; the rest is released automatically. This models hotel pre-authorizations: authorize $500, settle actual spend of $380.
  • timeout: a u32 number of seconds. After the timeout, TigerBeetle auto-expires the pending transfer. Your application does not need to poll or cancel — the reservation evaporates. Use this for ACH holds, card authorizations, and rate-limit windows.
  • Pessimistic accounting: flags.debits_must_not_exceed_credits checks debits_posted + debits_pending ≤ credits_posted at pending time, not at posting time. This guarantees no balance violation is possible when posting occurs — the check already passed.

Beancount parallel:

Beancount uses a ! (exclamation mark) flag on transactions to mark them as "incomplete" or "pending":

2024-03-15 ! "ACH withdrawal — pending"
  Assets:Checking    -500.00 USD
  Assets:ACH:Pending  500.00 USD

When settlement occurs, you replace the ! with * and adjust the accounts. The Assets:ACH:Pending account serves the same semantic role as debits_pending — it segregates committed-but-not-settled funds from posted balances. This is manual discipline in Beancount; TigerBeetle enforces it structurally.

Two-phase transfers and double-spend prevention:

The classic double-spend attack: two concurrent withdrawals, each for the full balance, both approved before either settles. Without pending, both see available = posted_balance and both succeed. With flags.pending and flags.debits_must_not_exceed_credits:

  • Transfer A creates a pending debit. debits_pending = 500.
  • Transfer B arrives. TigerBeetle checks: debits_pending + debits_posted + 500 > credits_posted → rejected.

TigerBeetle's serial state machine means these checks cannot race. All transfers are processed one-at-a-time, in order. No lock is needed because there is no concurrency within the state machine.


Socratic Dialogue

Q1: After a pending transfer is created, debits_posted on the debit account is unchanged. So the "posted balance" looks the same. Why does available balance decrease?

Answer

Available balance is computed as:

available = posted_balance - debits_pending
          = (credits_posted - debits_posted) - debits_pending   [credit-normal]

debits_pending is the reservation. It hasn't settled yet, but the funds are committed — TigerBeetle guarantees they will post when phase 2 runs. Showing the full credits_posted - debits_posted as available would be dishonest: those funds are already spoken for. The pending amount is subtracted to give the user the true spendable amount.

This is exactly what your bank does when you initiate a bill payment — posted balance unchanged, available balance drops.


Q2: A pending transfer has amount = 500. You post it with amount = 300. What happens to the remaining 200?

Answer

The remaining 200 is automatically released back to the debit account. When a post-pending transfer specifies an amount less than the pending amount:

  • debits_pending -= 500 (full reservation released)
  • debits_posted += 300 (only posted amount settles)
  • The remaining 200 never touches debits_posted — it simply ceases to be reserved.

The credit account mirrors this: credits_pending -= 500, credits_posted += 300.

This is essential for the hotel/car rental pattern: a $500 pre-authorization can settle to $312.47 without any manual cleanup of the difference.


Q3: What is the timeout field on a pending transfer? What happens if you set it and then forget about the transfer?

Answer

timeout is a u32 value in seconds representing the duration from the transfer's timestamp until automatic expiry. TigerBeetle's cluster manages time internally ("cluster time") — it does not use the client's clock.

If you set timeout = 3600 (1 hour) and never post or void the transfer:

  • After 3600 seconds, TigerBeetle automatically expires the pending transfer
  • debits_pending -= amount on the debit account
  • credits_pending -= amount on the credit account
  • The reserved funds are fully released

Your application does not need to run a cleanup job. The pending transfer is atomically expired by TigerBeetle. The only observable effect is the availability returning.

Note: expired pending transfers cannot be posted or voided afterward — they return pending_transfer_expired.


Q4: Why is the post/void of a pending transfer a new Transfer rather than a mutation of the original?

Answer

Three reasons:

  1. Immutability: TigerBeetle's data model is append-only. Transfers are never modified. This is not a limitation — it is the audit trail property. The pending transfer record shows when authorization happened; the post transfer record shows when settlement happened. Both are permanent, timestamped events.

  2. Idempotency: The post/void transfer has its own client-generated id. If the post request times out and is retried with the same id, TigerBeetle returns exists (idempotent). If the original pending transfer were modified instead, retrying a mutation is much harder to make idempotent correctly.

  3. Queryability: get_account_transfers returns both the pending and the posting transfer in sequence. You can see the full lifecycle: authorization at time T1, settlement at time T2, partial amount posted. This timeline is valuable for reconciliation, disputes, and compliance.


Q5: Two concurrent $500 withdrawals arrive for an account with credits_posted = 500, debits_posted = 0. TigerBeetle processes all transfers serially. Walk through what happens.

Answer

Assume flags.debits_must_not_exceed_credits is set.

Transfer A arrives first:

  • Check: debits_pending + debits_posted + 500 = 0 + 0 + 500 ≤ 500 = credits_posted
  • Creates pending: debits_pending = 500

Transfer B arrives second:

  • Check: debits_pending + debits_posted + 500 = 500 + 0 + 500 = 1000 > 500 = credits_posted
  • Rejected: exceeds_credits

Transfer A succeeds. Transfer B is rejected. No double-spend.

Without the flag: TigerBeetle has no basis to reject Transfer B. Both pending transfers are created. debits_pending = 1000. If both post, debits_posted = 1000 > credits_posted = 500. The account goes negative — a double-spend occurred. The flag is not optional for accounts where overdraft is impermissible.


Q6: An ACH transfer is initiated Friday evening. Settlement typically takes 1–3 business days. Model the TigerBeetle states through the following Monday.

Answer
Friday 5pm:   Pending transfer created
              debit_account.debits_pending   += ACH_amount
              credit_account.credits_pending += ACH_amount
              timeout = 72h (or 259200 seconds, covering the weekend)

[Weekend — no banking activity]

Monday 9am:   ACH network confirms settlement
              Post-pending transfer submitted:
              debit_account.debits_pending   -= ACH_amount
              debit_account.debits_posted    += ACH_amount
              credit_account.credits_pending -= ACH_amount
              credit_account.credits_posted  += ACH_amount

Monday 9am (rejection scenario):
              ACH returns NSF (insufficient funds at originating bank)
              Void-pending transfer submitted:
              debit_account.debits_pending   -= ACH_amount
              (nothing posts; funds fully released)

During the weekend, the sender's available balance correctly reflects the hold. The recipient's available balance does not increase (credits_pending, not credits_posted). This is correct: an ACH credit is not spendable until it posts.


Q7: Can a pending transfer be posted multiple times, or voided after being posted?

Answer

No. A pending transfer has exactly one resolution:

  • Posted → pending_transfer_already_posted if you try to post or void again
  • Voided → pending_transfer_already_voided if you try to resolve again
  • Expired → pending_transfer_expired if you try to resolve after expiry

This is enforced by TigerBeetle's state machine. The pending transfer ID (pending_id) can be referenced only once in a resolving transfer. Attempting to submit a second post creates a Transfer with a new id and pending_id pointing to the already-resolved pending — TigerBeetle rejects it at the appropriate error code.

The practical consequence: idempotency for phase 2 works via the posting transfer's own id, not via re-submitting the phase-2 action. Generate the posting transfer's id on the client, persist it, and retry with the same id — TigerBeetle returns exists if it already processed it.


Exercises

Exercise 6-A: State machine diagram

Draw (in text) the full state machine for a two-phase transfer. Show all states and transitions including error paths. Include:

  • The pending transfer states
  • All three resolution outcomes
  • Error states (account rejected, already resolved, expired)
Solution
                          ┌─────────────────────────────────┐
                          │   SUBMITTED (pending Transfer)   │
                          └─────────────────────────────────┘
                                          │
                    ┌─────────────────────┼──────────────────────┐
                    │ balance check fails  │ balance check passes  │
                    ▼                     ▼                       │
             ┌──────────┐         ┌────────────┐                 │
             │ REJECTED  │         │  PENDING   │                 │
             │(exceeds_  │         │(funds      │                 │
             │ credits)  │         │ reserved)  │                 │
             └──────────┘         └────────────┘                 │
                                        │                        │
               ┌────────────────────────┼──────────────┐         │
               │ post_pending_transfer  │ void_pending  │ timeout │
               ▼                       ▼      elapsed  ▼         │
        ┌────────────┐         ┌─────────────┐  ┌───────────┐    │
        │   POSTED   │         │   VOIDED    │  │  EXPIRED  │    │
        │(debits_    │         │(reservation │  │(auto-void │    │
        │ posted +=) │         │ released)   │  │ on expiry)│    │
        └────────────┘         └─────────────┘  └───────────┘    │
               │                      │                │          │
               └──────────────────────┴────────────────┘          │
                              │                                    │
                    Any further resolution attempt:                │
                    pending_transfer_already_posted /              │
                    pending_transfer_already_voided /              │
                    pending_transfer_expired                       │

Key: every state transition (post, void, expire) is itself a new immutable Transfer record. The state of a pending transfer is derivable from the transfer log — you don't need a separate status field.


Exercise 6-B: Hotel pre-authorization

A hotel checks in a guest and pre-authorizes a card for $800 (maximum possible stay cost). At checkout, the actual bill is $523.

(a) Write the TigerBeetle Transfer sequence. Specify all relevant flags and how the amount changes between phase 1 and phase 2. (b) At the moment of check-in, how does this appear on the guest's account (credits_posted = 1200, debits_posted = 0, debits_pending = 0 before check-in)? (c) If the guest checks out early and the bill is only $400, show the partial post. What happens to the remaining $400 of the reservation?

Solution

(a) Transfer sequence:

T1 (check-in, phase 1):
  debit_account_id:  guest_account
  credit_account_id: hotel_settlement_account
  amount:            800_00
  flags:             pending
  timeout:           604800  (7 days, covers max expected stay)
  code:              AUTH_HOLD

T2 (checkout, phase 2):
  pending_id:        T1.id
  debit_account_id:  guest_account         (or 0 — must match T1 or be 0)
  credit_account_id: hotel_settlement_account
  amount:            523_00                (actual bill)
  flags:             post_pending_transfer
  code:              SETTLEMENT

After T2: debits_pending -= 800, debits_posted += 523. Remaining 277 auto-released.

(b) After check-in (T1 posts as pending):

  • credits_posted = 1200 (unchanged — posted balance not affected)
  • debits_pending = 800
  • available = credits_posted - debits_posted - debits_pending = 1200 - 0 - 800 = 400

The guest can spend $400 during the stay. Their card statement shows the full $1,200 posted balance but only $400 available.

(c) Early checkout, $400 bill:

T2 (early checkout):
  pending_id: T1.id
  amount:     400_00
  flags:      post_pending_transfer

After T2:

  • debits_pending -= 800 (full reservation released)
  • debits_posted += 400 (only bill settles)
  • Remaining $400 of the reservation is gone — no additional action needed by the application.

Final state: debits_posted = 400, debits_pending = 0, available = 800.


Exercise 6-C: The expiry window design question

You are designing an ACH debit system. ACH returns can arrive up to 60 days after the original debit for unauthorized transactions (R10-R29 return codes), but standard NSF returns arrive within 2 business days.

(a) What timeout would you set on the pending transfer? Justify the trade-off. (b) You decide to set timeout = 172800 (48 hours, covering the NSF window). An unauthorized-transaction return arrives on day 45. What is the TigerBeetle state of the original pending transfer? What do you do? (c) A return arrives on day 1, while the pending transfer is still active. Walk through the void sequence.

Solution

(a) There is no single correct answer — this is a design decision with real trade-offs.

Option 1: 48-hour timeout — Covers NSF returns. For the 60-day unauthorized window, you accept that the pending transfer will expire and you'll handle late returns with correcting Transfers.

Option 2: No timeout (timeout = 0) — Pending transfer stays open indefinitely. Requires your application to explicitly post or void on return notification. Funds remain reserved, which is correct but may confuse users who see a perpetual hold.

Recommendation: 48-hour timeout for the pending/settlement window; treat 60-day returns as correcting transfers against the already-posted balance. The 60-day window is not a "hold" scenario — it is a reversal of a completed transaction.

(b) On day 45, the pending transfer expired on day 2. TigerBeetle state: the funds were released, debits_pending = 0. If the transfer also posted on day 1 (normal ACH settlement), debits_posted reflects the settled amount.

The day-45 return is now a correcting transfer: submit a Transfer that credits the user's account for the returned amount, debits the ACH returns account, with code = RETURN and user_data_128 = original_ACH_order_id. This is a new, separate Transfer — not a void (the original pending transfer is long gone).

(c) Return arrives day 1, pending transfer still active:

T_void:
  pending_id:            T_original.id
  debit_account_id:      0 (or match original)
  credit_account_id:     0 (or match original)
  amount:                0 (or match original)
  flags:                 void_pending_transfer
  code:                  ACH_RETURN
  user_data_128:         original_ACH_order_id

Effect:

  • debits_pending -= amount (reservation released)
  • credits_pending -= amount (merchant's pending credit released)
  • Nothing posts. The transaction is fully unwound.

The original pending transfer remains in history with flags.pending = true. The void transfer is a new record showing the return. Both are queryable forever.


Source reading for this module:


⚡ Interlude Challenge 3 (after Module 6)

Synthesis question: An ACH debit is initiated on Friday afternoon. Model the complete state machine — from submission through the following Monday — with concrete TigerBeetle field values at each step. Include: the optimistic path (normal settlement), the NSF return path (bank rejects on Monday), and the unauthorized-transaction return path (user disputes 30 days later). For each path, show every Transfer submitted and the final account state.

Discussion

Accounts:

  • user_account: user's USD balance (Liability, debits_must_not_exceed_credits)
  • ach_transit: internal transit/clearing account
  • ach_returns: account for returned items

Friday 4pm — Initiation:

T1 (pending):
  debit:   user_account
  credit:  ach_transit
  amount:  50000  ($500.00)
  flags:   pending
  timeout: 259200  (72h — covers weekend)
  code:    ACH_DEBIT

State: user_account.debits_pending = 50000
       ach_transit.credits_pending = 50000
       user_account.available      = was_500 - 500 = 0

Monday 9am — Path A: Normal settlement:

T2 (post):
  pending_id: T1.id
  amount:     50000
  flags:      post_pending_transfer
  code:       ACH_SETTLEMENT

State: user_account.debits_pending  = 0
       user_account.debits_posted   += 50000
       ach_transit.credits_pending  = 0
       ach_transit.credits_posted   += 50000

Final: user owes $500, ACH transit holds $500 for bank sweep.

Monday 9am — Path B: NSF return:

T2 (void):
  pending_id: T1.id
  flags:      void_pending_transfer
  code:       ACH_RETURN_NSF

State: user_account.debits_pending  = 0  (released)
       ach_transit.credits_pending  = 0  (released)
       — nothing posted. Transaction unwound.

30 days later — Path C: Unauthorized-transaction return (R10):

At this point T1 has long expired (72h timeout). T1 already settled on Monday (Path A). User disputes.

T3 (correcting credit):
  debit:   ach_returns
  credit:  user_account
  amount:  50000
  code:    ACH_RETURN_UNAUTHORIZED
  user_data_128: original_ACH_order_id

State: user_account.credits_posted  += 50000  (user refunded)
       ach_returns.debits_posted     += 50000  (returns bucket debited)

This is not a void — the original posting was real and is now reversed by a new correcting transfer. The bank dispute process determines whether ach_returns is funded by the merchant or absorbed as a loss.

Key insight: The three paths use different mechanisms — post, void, and correcting transfer — because they occur at different stages of the transfer lifecycle. Understanding which mechanism applies requires knowing whether the pending transfer is still active.

Multi-Currency and Commodities

Concept

A "currency" is a unit of measure. The conservation invariant — sum(postings) = 0 — holds within a unit, not across units. 100 USD and 100 EUR are not the same thing. You cannot add them. Any system that allows cross-unit arithmetic without an explicit conversion is broken.

Beancount: the Inventory

An account in Beancount holds an Inventory: a mapping from (commodity, cost_basis, acquisition_date) tuples to quantities. Unlike plain numbers, lots are kept separate because their cost basis determines P/L at sale time.

A purchase of stock creates an inventory position:

2024-01-15 * "Buy AAPL"
  Assets:Brokerage:AAPL   10 AAPL {175.00 USD}
  Assets:Brokerage:Cash  -1750.00 USD

2024-06-01 * "Buy more AAPL"
  Assets:Brokerage:AAPL   10 AAPL {200.00 USD}
  Assets:Brokerage:Cash  -2000.00 USD

The inventory of Assets:Brokerage:AAPL is now two distinct lots, not one merged position:

  10 AAPL {175.00 USD, 2024-01-15}
  10 AAPL {200.00 USD, 2024-06-01}

They cannot be merged because they have different cost bases — and that difference determines how much tax you owe when you sell.

Booking methods resolve which lot gets reduced on a sale when the match is ambiguous:

MethodRule
STRICTError if ambiguous. You must specify the lot explicitly.
FIFOReduce oldest lots first.
LIFOReduce newest lots first.
AVERAGEMerge all lots into one with averaged cost. No lot-level history.
NONEAppend everything; no matching attempted. For non-taxable accounts.

The choice of booking method changes reported income — and therefore tax liability — by potentially large amounts on the same underlying trades.

Currency exchange in Beancount uses a price annotation:

2024-03-01 * "Convert USD to EUR"
  Assets:Bank:EUR   920.00 EUR @ 1.0870 USD
  Assets:Bank:USD  -1000.00 USD

The @ 1.0870 USD annotation is the price (spot rate at time of transaction). The { } syntax is for cost basis. Price annotations are used for reporting; cost basis annotations are used for P/L tracking. They're different things.

TigerBeetle: Ledgers as Currency Partitions

In TigerBeetle, ledger is a u32 field on both Account and Transfer that acts as a namespace for currency. Transfers between accounts on different ledgers are rejected — the database enforces the unit boundary structurally.

Account:
  id:     1001
  ledger: 840    // ISO 4217: USD
  code:   100    // application-defined: "user account"

Account:
  id:     1002
  ledger: 978    // ISO 4217: EUR
  code:   100

A transfer from account 1001 to account 1002 fails: different ledgers. There is no "exchange rate" concept at the database layer. That logic lives in your application.

Cross-currency pattern: four accounts, two linked transfers

Accounts involved:
  A₁  — user's USD account  (ledger: 840)
  L₁  — LP's USD account    (ledger: 840)   ← liquidity provider source
  L₂  — LP's EUR account    (ledger: 978)   ← liquidity provider destination
  A₂  — user's EUR account  (ledger: 978)

Transfer sequence:
  T₁: debit A₁, credit L₁, amount=100000 (USD, $1000.00), flags.linked=true
  T₂: debit L₂, credit A₂, amount=92000  (EUR,  €920.00), flags.linked=false

T₁ and T₂ are atomic via flags.linked. Either both commit or neither does. The exchange rate is implicit in the amounts. The liquidity provider holds two accounts — one per currency — and the net movement across both ledgers keeps them square.

Spread: Record exchange rate and fee as separate linked transfers rather than baking the spread into the rate. This makes the exchange rate and fee independently auditable.

T₁: A₁ → L₁, amount=100000, flags.linked=true   (principal)
T₂: A₁ → L₁, amount=100,    flags.linked=true   (fee: $1.00)
T₃: L₂ → A₂, amount=92000,  flags.linked=false  (EUR delivery)

Socratic Dialogue

Q1: TigerBeetle rejects transfers between accounts on different ledgers. Why isn't this just an application-layer rule? Why does it belong in the database?

Answer

For the same reason you enforce foreign key constraints in the database rather than in your app: consistency cannot be optional. If only application code enforces the ledger boundary, a bug, a direct DB write, a race condition, or a future engineer who didn't know the rule can violate it silently.

A cross-ledger transfer that gets through would create a debit in USD and a credit in EUR — units that don't cancel. The trial balance becomes sum(all_postings) = 0 in each ledger independently, but you can no longer check this property globally. The structural invariant has a hole in it.

Database-level enforcement means: it is impossible for the invariant to be violated, not merely unlikely.


Q2: Beancount's inventory keeps lots separate. What information is lost if you merge all lots of the same commodity into a single quantity at average cost?

Answer

You lose:

  1. Acquisition date per lot — required for long-term vs short-term capital gains determination (e.g., the 12-month holding period in US tax law).
  2. Cost basis per lot — required to compute per-lot P/L. With average cost, you only know the blended P/L across all lots, which may be legally incorrect if the jurisdiction requires lot-specific reporting.
  3. The ability to choose specific lots — specific identification lets you choose which lot to sell to minimize taxes (e.g., sell the highest-cost lot to minimize realized gain). Averaging collapses this choice.

The AVERAGE booking method is appropriate for tax-sheltered accounts (401k, RRSP) where individual lot taxation doesn't matter, not for taxable brokerage accounts.


Q3: The exchange rate at the moment of a USD→EUR conversion is 1.0000 USD = 0.9200 EUR. T₁ debits $1000 from the user. T₂ credits the user with €920. These two transfers are not linked. T₁ posts. T₂ fails (EUR account not found — bug in account creation flow). What is the financial state? What has been violated?

Answer

Financial state:

  • User's USD account: debits_posted += $1000 (user is out $1000)
  • User's EUR account: no credit (€0 received)
  • Liquidity provider's USD account: credits_posted += $1000 (LP received $1000)
  • Liquidity provider's EUR account: unchanged

The user has lost $1000 and received nothing. The conservation invariant is violated across the two-transfer operation (not within either individual ledger — each ledger still balances, but the economic intent — trade $1000 for €920 — has half-executed).

What has been violated is atomicity of the exchange. The two transfers were supposed to be a single economic unit but were not made atomic. This is exactly what flags.linked prevents: if T₂ fails, T₁ is rolled back. Non-linked transfers don't share fate.

Detection: reconcile your LP's USD account (received $1000) against EUR delivery account (sent €0). The imbalance shows up there.

Fix: manually issue T₂ as a correcting credit, or void the operation and reissue the full amount. There's no mechanical fix — you have to know it happened.


Q4: A Beancount account is opened with option "booking_method" "STRICT". You attempt to sell 10 shares of HOOL with an empty lot spec {}, and the account contains two lots at different prices. What happens? How do you fix it?

Answer

Beancount raises an error: ambiguous reduction. Under STRICT, it refuses to guess which lot to reduce.

Fix options:

  1. Specify the cost basis: Assets:Invest -10 HOOL {23.00 USD} — matches the lot at that price.
  2. Specify the acquisition date: Assets:Invest -10 HOOL {2015-04-01} — matches by date.
  3. Specify a label if you labeled the lot at purchase: Assets:Invest -10 HOOL {"first-lot"}.
  4. Change the account's booking method to FIFO or LIFO if automatic selection is acceptable.

The error is a feature, not a bug. STRICT forcing you to be explicit ensures that P/L is computed against the lot you actually intend to reduce, which may have significant tax implications.


Q5: A user has a USD account and an EUR account in TigerBeetle. They want to convert €500 to USD. How many TigerBeetle accounts are involved in this operation at minimum? What determines the exchange rate?

Answer

Minimum four accounts:

  1. user_eur — user's EUR account (ledger: EUR)
  2. lp_eur — liquidity provider's EUR account (ledger: EUR)
  3. lp_usd — liquidity provider's USD account (ledger: USD)
  4. user_usd — user's USD account (ledger: USD)
T₁: debit user_eur, credit lp_eur, amount=50000 (€500.00), linked=true
T₂: debit lp_usd,   credit user_usd, amount=54350 ($543.50), linked=false

The exchange rate is determined entirely by the amounts your application sets on T₁ and T₂. TigerBeetle has no opinion. The ratio 54350 / 50000 = 1.087 implies a rate of 1 EUR = 1.087 USD. If you change the amounts, you change the effective rate. The database enforces conservation within each ledger; the rate between ledgers is your application's responsibility.


Exercises

Exercise 7-A: The split transaction (core adversarial hook)

You submit the following two TigerBeetle transfers as separate non-linked requests (not in the same batch, not with flags.linked):

T₁: debit=user_usd (ledger:840), credit=lp_usd (ledger:840), amount=10000
T₂: debit=lp_eur  (ledger:978), credit=user_eur (ledger:978), amount=9200

T₁ posts successfully. T₂ fails: lp_eur account doesn't exist yet (not created).

(a) What is the exact state of every account involved? (b) How do you detect this inconsistency automatically? (c) What are your recovery options? For each, state what Transfer(s) you issue. (d) Had these been linked (T₁.flags.linked = true), what would have happened instead?

Solution

(a) Account states after failure:

AccountΔ debits_postedΔ credits_postedNet
user_usd+100000-$100.00 (user lost)
lp_usd0+10000+$100.00 (LP gained USD)
lp_euraccount doesn't exist
user_eur00unchanged

Conservation within each ledger holds: USD ledger balances (lp_usd received what user_usd sent). EUR ledger was never touched. But the economic transaction — a currency exchange — is half-executed.

(b) Detection:

Reconcile your liquidity provider's position. lp_usd.credits_posted should always be matched by a corresponding lp_eur.debits_posted (at the exchange rate). Query get_account_transfers on lp_usd filtered by code=CURRENCY_EXCHANGE and join against EUR deliveries. Any USD receipt without a corresponding EUR delivery is a stuck exchange.

You can also build this as a balance assertion: the LP should be net-zero on exchanges (received USD = delivered EUR * rate). If not, there's an unmatched leg.

(c) Recovery options:

Option 1 — Complete the exchange (if EUR account now exists or can be created):

T₃: debit=lp_eur, credit=user_eur, amount=9200
    user_data_128: original_order_id   // links to the original order
    code: CURRENCY_EXCHANGE_COMPLETION

The exchange is completed. The LP's positions become square. This is correct if the exchange rate is still acceptable and the user wants the EUR.

Option 2 — Reverse the exchange (refund USD):

T₃: debit=lp_usd, credit=user_usd, amount=10000
    user_data_128: original_order_id
    code: CURRENCY_EXCHANGE_REVERSAL

The user gets their USD back. The LP returns to original state. Use this if the EUR delivery cannot be completed and the user wants their money back.

Both are new Transfers. You cannot modify or delete T₁ — it's immutable.

(d) Linked behavior:

With T₁.flags.linked = true and T₂ in the same batch, if T₂ fails, T₁ is rolled back atomically. Neither transfer posts. All accounts remain unchanged. This is the correct design — the entire exchange is an atomic unit and either both legs commit or neither does.

Key design rule: any operation that spans multiple ledgers must use flags.linked. Non-linked multi-ledger operations are structurally racy and create half-executed states that require manual reconciliation.


Exercise 7-B: Booking method P/L comparison

You hold AAPL in a taxable account:

  • Lot A: 10 shares purchased at $150.00 (2023-01-10)
  • Lot B: 10 shares purchased at $200.00 (2024-01-10)

AAPL is currently at $180. You sell 10 shares today (2025-01-15).

(a) Compute realized P/L under FIFO, LIFO, and average cost. (b) In the US, Lot A qualifies for long-term capital gains (held >1 year); Lot B does not (held <1 year as of the sale date — wait, check). Recalculate which lots qualify. (c) Write the Beancount transaction for each method. Show the Income:CapGains posting. (d) Which method minimizes your tax bill and why?

Solution

(a) Realized P/L:

Proceeds: 10 × $180 = $1,800

MethodLot(s) soldCost basisRealized P/L
FIFOLot A (10 × $150)$1,500+$300
LIFOLot B (10 × $200)$2,000−$200
Average(10 × $175 avg)$1,750+$50

Average cost = ($1,500 + $2,000) / 20 shares = $175.00/share.

(b) Long-term vs short-term:

Sale date: 2025-01-15.

  • Lot A purchased 2023-01-10 → held ~2 years → long-term (lower US tax rate)
  • Lot B purchased 2024-01-10 → held ~1 year and 5 days → long-term (just barely, >365 days)

Both lots qualify for long-term treatment. The rate difference argument still applies if Lot B's purchase date were, say, 2024-07-10 (< 1 year). In that scenario Lot B would be short-term.

(c) Beancount transactions:

FIFO (sell Lot A):

2025-01-15 * "Sell 10 AAPL (FIFO)"
  Assets:Brokerage:AAPL   -10 AAPL {150.00 USD, 2023-01-10}
  Assets:Brokerage:Cash  1800.00 USD
  Income:CapGains:LongTerm  -300.00 USD

LIFO (sell Lot B):

2025-01-15 * "Sell 10 AAPL (LIFO)"
  Assets:Brokerage:AAPL   -10 AAPL {200.00 USD, 2024-01-10}
  Assets:Brokerage:Cash  1800.00 USD
  Income:CapGains:LongTerm  200.00 USD   ; loss — negative income

Note: Income accounts are credit-normal; a gain is a credit (negative in Beancount's signed notation), a loss is a debit (positive). This can be confusing — verify that postings sum to zero.

Average cost (requires AVERAGE booking or manual calculation):

2025-01-15 * "Sell 10 AAPL (avg cost)"
  Assets:Brokerage:AAPL   -10 AAPL {175.00 USD}
  Assets:Brokerage:Cash  1800.00 USD
  Income:CapGains:LongTerm   -50.00 USD

(d) Tax minimization:

LIFO produces a $200 loss, which can offset other capital gains (or up to $3,000 of ordinary income in the US). If you have other gains to offset, LIFO is best.

If you have no gains to offset, FIFO's $300 gain at long-term rates (15% for most brackets) costs $45 in tax — still much less than the LIFO loss has value as an offset.

If you want to realize gains (e.g., to reset cost basis before year-end at low tax rates), FIFO is best.

The deeper point: the choice of booking method doesn't change the underlying economic position (you own the same shares either way), but it changes which tax obligation you realize now versus later. This is why lot-level tracking is legally required in taxable accounts and why STRICT booking is the safest default.


Exercise 7-C: Multi-currency account schema design

A neobank lets users hold balances in USD, EUR, and GBP simultaneously. Design the minimal TigerBeetle account schema for one user. Then model a user converting $500 USD to €460 EUR through the bank's internal LP, with a $2 fee.

(a) List all accounts (with their ledger values). (b) Write the full linked Transfer sequence, with flags.linked correctly set on each. (c) After the conversion, what is the state of the LP's USD and EUR accounts? Is the LP square?

Solution

(a) Accounts:

For one user:

user_usd  (ledger: 840, code: USER_BALANCE)
user_eur  (ledger: 978, code: USER_BALANCE)
user_gbp  (ledger: 826, code: USER_BALANCE)

For the internal LP (one set, shared across all users):

lp_usd  (ledger: 840, code: LP_ACCOUNT)
lp_eur  (ledger: 978, code: LP_ACCOUNT)
lp_gbp  (ledger: 826, code: LP_ACCOUNT)

For revenue tracking:

fee_usd  (ledger: 840, code: REVENUE)

Total per currency: 3 account types × 3 currencies = 9 accounts minimum (LP and fee could be shared but user accounts are per-user).

(b) Transfer sequence for $500 → €460 with $2 fee:

Amounts as integers (cents): $500.00 = 50000, €460.00 = 46000, $2.00 = 200.

T₁: debit=user_usd, credit=lp_usd,   amount=50000, ledger=840
    flags.linked = true
    code = FX_PRINCIPAL

T₂: debit=user_usd, credit=fee_usd,  amount=200,   ledger=840
    flags.linked = true
    code = FX_FEE

T₃: debit=lp_eur,   credit=user_eur, amount=46000, ledger=978
    flags.linked = false   (last in chain)
    code = FX_DELIVERY

All three are submitted in one batch. T₁ and T₂ must both have flags.linked = true. T₃ must have flags.linked = false (it's the last transfer). If any fails, all are rolled back.

The fee is recorded separately (T₂) rather than folded into the exchange rate, so the rate ($500 → €460, i.e., 1 USD = 0.92 EUR) is auditable independently of the revenue.

(c) LP account state after conversion:

AccountΔ credits_postedΔ debits_postedNet effect
lp_usd+500000LP received $500
lp_eur0+46000LP delivered €460

Is the LP square? In USD terms: received $500, delivered €460 (worth ~$500 at the 0.92 rate). At exactly the quoted rate, yes — the LP breaks even on the exchange. The $2 fee goes to fee_usd, not to the LP. If the bank operates the LP, it profits from the fee; if an external LP is used, a separate settlement process reconciles the LP's cross-currency exposure.

The LP's net position across ledgers is always non-zero in raw numbers (it holds USD, owes EUR delivery obligations). Squareness is checked by application logic against the current market rate, not by TigerBeetle — which only enforces within-ledger conservation.


Source Reading

  • beancountdocs/docs/how_inventories_work.md — Introduction through Summary (booking methods, augmentation vs reduction, FIFO/LIFO/AVERAGE/NONE)
  • beancountdocs/docs/trading_with_beancount.md — Trade Lots, Dated lots, booking method selection
  • beancountdocs/docs/beancount_design_doc.md — Number, Commodity, Amount, Lot, Position, Inventory sections
  • tigerbeetledocs/coding/data-modeling/index.html — Ledgers section, multi-currency accounts
  • tigerbeetledocs/coding/recipes/currency-exchange/index.html — Data Modeling, Example, Spread

Interlude: After Module 6

Synthesis question: An ACH debit is initiated Friday afternoon. Model the complete state machine — (pending → posted) or (pending → voided) — with concrete TigerBeetle account states through the following Monday. Include the account balances at each state transition. Assume: user has $500 available, the ACH amount is $300, NSF return arrives Monday morning.

Discussion

Account setup:

user_account:    ledger=840, flags=debits_must_not_exceed_credits
ach_transit:     ledger=840  (clearing account, no balance flags)

Friday 3:30pm — Submission:

T₁ (pending):
  debit:   user_account
  credit:  ach_transit
  amount:  30000
  flags:   pending
  timeout: 259200  (72 hours)
  code:    ACH_INITIATED

State:

user_account:  credits_posted=50000, debits_pending=30000
               available = 50000 - 0 - 30000 = 20000
ach_transit:   credits_pending=30000

Friday → Saturday → Sunday (weekend — no banking activity):

T₁ is still pending. debits_pending holds the $300 reserved. The user cannot spend it (available = $200). The 72-hour timeout has not elapsed.

Monday 9am — NSF return (bank confirms insufficient funds):

T₂ (void):
  pending_id: T₁.id
  flags:      void_pending_transfer
  code:       ACH_RETURN_R01

State after void:

user_account:  credits_posted=50000, debits_pending=0
               available = 50000 (restored)
ach_transit:   credits_pending=0

Nothing was posted. The $300 was never moved. The user's account is back to where it started.

Monday 9am — Successful settlement (alternative path):

T₂ (post):
  pending_id: T₁.id
  amount:     30000
  flags:      post_pending_transfer
  code:       ACH_SETTLED

State after post:

user_account:  credits_posted=50000, debits_posted=30000, debits_pending=0
               available = 50000 - 30000 = 20000
ach_transit:   credits_posted=30000, credits_pending=0

The $300 is now permanently moved. The ach_transit account holds $300 awaiting bank sweep.

Key timing insight: the 72-hour timeout on T₁ ensures that if neither post nor void arrives (e.g., the ACH processor crashes), the pending transfer auto-expires and funds are released. The user is not left with permanently frozen funds due to a downstream system failure. This is the "auto-void after timeout" guarantee from Module 6.

P/L, Realized vs Unrealized, and Corporate Actions

Concept

P/L is not a number you store — it is a number you derive. That derivation requires knowing two things: (1) what you paid for an asset (cost basis), and (2) what you received when you sold it (or what the market says it's worth if you haven't sold yet). Every complexity in this module is a consequence of one of those two quantities being ambiguous.

The P/L formula

P/L = Sale Proceeds − Cost Basis of Lots Sold

For unsold positions:

Unrealized P/L = Current Market Value − Cost Basis of Remaining Lots

Neither number means anything without understanding which lots were sold and what the cost basis of those lots was. This is why booking methods exist.

How Beancount represents realized P/L

When you sell shares, Beancount removes them from inventory at their acquisition cost — not at the sale price. The difference between sale proceeds and cost basis doesn't balance the transaction on its own. A separate Income:PnL leg absorbs the imbalance and becomes the realized gain or loss:

; Buy: 10 IBM at $160 each
2024-01-10 * "Buy IBM"
  Assets:Brokerage:IBM   10 IBM {160.00 USD}
  Assets:Brokerage:Cash  -1600.00 USD

; Sell: 3 IBM at $170 each (proceeds = $510)
2024-02-17 * "Sell IBM"
  Assets:Brokerage:IBM   -3 IBM {160.00 USD} @ 170.00 USD
  Assets:Brokerage:Cash   510.00 USD
  Income:PnL                          ; auto-filled: -30.00 USD

The @ 170.00 USD annotation records the sale price for auditing and price database purposes — Beancount ignores it for balancing. What actually balances the transaction is the fact that you removed inventory at cost ($480) and received $510 in cash. The imbalance is $30, which Beancount auto-fills as -30.00 USD in Income:PnL. Income accounts are credit-normal: a negative amount is a profit, a positive amount is a loss.

Unrealized P/L in Beancount

Unrealized P/L is hypothetical. Your books hold positions at cost basis; they say nothing about current market value unless you tell the system current prices. Beancount's approach:

; Price entry — manually or via fetch script
2024-03-01 price IBM  182.00 USD

; Enable the plugin to synthesize unrealized P/L entries
plugin "beancount.plugins.unrealized" "Unrealized"

The plugin creates a synthetic transaction at report time:

2024-03-01 U "Unrealized gain for 7 units of IBM (price: 182.00, cost: 160.00)"
  Assets:Brokerage:IBM:Unrealized    154.00 USD
  Income:IBM:Unrealized             -154.00 USD

This entry is synthetic — it does not represent a real transaction, no money moved. It is injected purely for the P/L report. Remove the plugin and it disappears.

Commissions and cost basis

Trading commissions complicate P/L. There are two strategies:

Simple (separate account): book commissions to Expenses:Commissions. Subtract separately when computing net P/L. This is an approximation — it understates the P/L in the year of acquisition and overstates it in years of sale because the acquisition commission is not pro-rated across lots.

Precise (fold into cost basis): add the acquisition commission to the position's book value. If you buy 100 shares at $80 with a $10 commission, the per-share cost basis becomes $80.10 instead of $80.00. When 40 shares are sold, exactly $4.04 of acquisition commission is embedded in the cost basis of those shares — automatic pro-ration.

2024-01-01 * "Buy 100 ITOT at $80 + $10 commission"
  Assets:Brokerage:ITOT  100 ITOT {80.10 USD}  ; folded commission
  Assets:Brokerage:Cash  -8010.00 USD

The $10 commission disappears into the cost basis. No separate Expenses:Commissions posting. At sale, the gain is automatically reduced by the pro-rated acquisition commission.

Corporate actions

Stock split 2:1: Beancount has no native split directive. You empty and recreate the position:

2024-06-01 * "2-for-1 stock split"
  Assets:Brokerage:ADSK  -100 ADSK {66.30 USD}
  Assets:Brokerage:ADSK   200 ADSK {33.15 USD}

The postings balance ($0 net). Cost basis per share halves; total cost basis is unchanged. If you care about holding period for tax purposes, use dated lots ({33.15 USD, 2020-01-15}) so the original acquisition date is preserved on the new lots.

Dividends (cash): no lot matching required — just income:

2024-03-15 * "Quarterly dividend"
  Assets:Brokerage:Cash    171.02 USD
  Income:Dividends

Dividends (stock, DRIP): the new shares enter inventory at their cost basis (market price on reinvestment date):

2024-03-15 * "DRIP — dividend reinvestment"
  Assets:Brokerage:AAPL   0.234 AAPL {182.50 USD}
  Income:Dividends

These shares become new lots with their own acquisition date — which matters for long-term/short-term determination at sale.

TigerBeetle: P/L as a query, not a field

TigerBeetle does not compute P/L. There is no realized_gain field. All value flows through the transfer log. To construct an income statement:

  1. Create dedicated accounts for each revenue/expense category (e.g., pnl_account for capital gains, dividend_account for dividends, commission_account for fees).
  2. When a sale occurs, issue two linked transfers: one that moves proceeds from the buyer into the user's cash account, one that books the cost basis offset into the P/L account. The net of those two transfers is the realized gain.
  3. At report time, call get_account_balances on each income/expense account. The balance is the period's P/L.
  4. For unrealized P/L: query get_account_transfers on the position account to reconstruct cost basis, fetch current market prices from your price feed, and compute the difference in your application layer.

TigerBeetle's transfer log is the source of truth. Your application layer is the computation engine. This is not a limitation — it's a deliberate separation: TigerBeetle ensures the ledger is correct; your code decides what "P/L" means for your product.


Socratic Dialogue

Q1: You sold 3 IBM shares you bought at $160 for $170 each. You write: Assets:Brokerage:Cash 510.00 USD and Assets:Brokerage:IBM -3 IBM {160.00 USD}. Why doesn't this balance? Where does the $30 go, and what account type does it go to?

Answer

The debit side: removing inventory valued at 3 × $160 = $480 from Assets:Brokerage:IBM. The credit side: receiving $510 into Assets:Brokerage:Cash. Net imbalance: $510 − $480 = $30 unaccounted.

The $30 goes to an Income account — Income:CapGains or Income:PnL. Income accounts are credit-normal. A gain is recorded as a credit (negative in Beancount's signed notation), which means -30.00 USD in the Income leg. This brings the sum of all postings to zero:

+510  (Cash received)
-480  (Inventory at cost removed)
-30   (Gain booked to Income)
───
  0   ✓

The $30 is not stored as a field on a row somewhere. It emerges from the arithmetic as a consequence of properly recording both sides of the trade.


Q2: What is unrealized P/L, exactly? Is it a real posting in Beancount? Why is it shown separately from realized P/L?

Answer

Unrealized P/L is the difference between the current market value of a held position and its cost basis. It is hypothetical — no transaction has occurred, no money has moved. The position could change value again before any sale.

In Beancount, unrealized P/L is not part of the real transaction log. It is injected by the beancount.plugins.unrealized plugin as a synthetic transaction at report time, using the most recent price entry for the commodity. Remove the plugin and it vanishes. Run the report at a different date and the number changes.

It is shown separately from realized P/L because:

  1. Realized P/L is a taxable event (in most jurisdictions). Unrealized P/L is not taxed until sale.
  2. Mixing them obscures when income was actually recognized.
  3. Unrealized P/L reverses itself — if the position falls back to cost, the unrealized gain disappears. Realized P/L is permanent.

Q3: You bought 100 shares of ITOT at $80 with a $10 acquisition commission. You sell 40 shares at $82 with a $10 sale commission. Compute the realized P/L for this sale under two methods: (a) commission as a separate expense, (b) commission folded into cost basis. Are the numbers different?

Answer

(a) Separate expense account:

Proceeds:        40 × $82 = $3,280.00
Cost basis:      40 × $80 = $3,200.00
Gross P/L:                    $80.00
Sale commission:              -$10.00
Acq. commission:              -$10.00  (total, not pro-rated)
Net P/L:                      $60.00  ← wrong allocation

This is incorrect. The $10 acquisition commission covers all 100 shares. Only 40% applies to this sale: $10 × (40/100) = $4.00. Reporting the full $10 against a 40-share sale over-reports the expense in year 1 and under-reports it in year 2 (when the remaining 60 shares are sold).

(b) Folded into cost basis:

Per-share cost basis: ($80 × 100 + $10) / 100 = $80.10
Cost of 40 shares:    40 × $80.10 = $3,204.00
Proceeds:             40 × $82   = $3,280.00
Gross P/L:                           $76.00
Sale commission:                     -$10.00
Net P/L:                             $66.00  ← correct allocation

Yes, the numbers differ per period (though the total P/L across both sales is the same either way). Method (b) allocates the acquisition cost correctly via cost basis. The remaining 60 shares carry $80.10 cost basis, embedding the remaining $6 of acquisition commission automatically — no manual tracking required.


Q4: A stock splits 2:1. You hold 10 shares at $200 cost basis (acquired 2022-01-10). After the split you hold 20 shares at $100 each. You sell 20 shares two years later at $150. Under US tax law, are these short-term or long-term gains? How does your Beancount transaction for the split affect the answer?

Answer

The holding period for split shares follows the original acquisition date, not the split date. The split is not a taxable event — it is a recapitalization. The 20 shares you hold after the split are still considered acquired on 2022-01-10.

If you record the split without dates:

2024-06-01 * "2-for-1 split"
  Assets:Brokerage:AAPL  -10 AAPL {200.00 USD}
  Assets:Brokerage:AAPL   20 AAPL {100.00 USD}

Beancount loses the original acquisition date. The new lots appear to have been acquired on 2024-06-01 — wrong.

Correct recording with dated lots:

2024-06-01 * "2-for-1 split"
  Assets:Brokerage:AAPL  -10 AAPL {200.00 USD, 2022-01-10}
  Assets:Brokerage:AAPL   20 AAPL {100.00 USD, 2022-01-10}

Now the holding period is preserved. When sold two years after the original acquisition (2026+), the gain is long-term. If you had failed to include the original date, Beancount would report the split date as acquisition — potentially misclassifying a long-term gain as short-term, with real tax consequences.


Q5: TigerBeetle has no P/L field and no income statement. A VC asks for last quarter's trading revenue from your brokerage app. Describe the exact queries you run and the application-layer computation.

Answer

Setup assumption: your schema designates a realized_pnl account per user (or per asset class), credit-normal, ledger=840 (USD). Every sale books the gain/loss there as part of a linked transfer chain.

Step 1 — Get realized P/L for the quarter:

get_account_balances([realized_pnl_account_id])

The balance of this account is the sum of all credited gains minus all debited losses posted during the account's lifetime. To isolate Q4, use get_account_transfers filtered by timestamp range:

get_account_transfers(
  account_id: realized_pnl_account_id,
  timestamp_min: Q4_start_ns,
  timestamp_max: Q4_end_ns
)

Sum the amount fields of all transfers that credited this account (sale proceeds booked) minus those that debited it (loss postings).

Step 2 — Unrealized P/L:

There is no TigerBeetle query for this. You must:

  1. Call get_account_transfers on each position account, reconstruct the inventory (which lots are still held and at what cost basis).
  2. Fetch current market prices from your price feed.
  3. Compute (current_price × quantity) − cost_basis in application code.

Step 3 — Income statement line items:

Each income category (trading gains, dividends, commissions) is a separate TigerBeetle account. The income statement is get_account_balances across all of them — one read per account type.

The key insight: TigerBeetle gives you an immutable, consistent transfer log. Your application turns that log into semantically meaningful reports. The database guarantees the log is correct; you decide what the log means.


Exercises

Exercise 8-A: Commission pro-rating (adversarial)

You run a brokerage. Users are charged a flat $10 commission per trade regardless of size. A user executes:

  • 2024-01-01: Buy 100 ITOT at $80.00 per share. Commission: $10.
  • 2024-11-01: Sell 40 ITOT at $82.00 per share. Commission: $10.
  • 2025-02-01: Sell 60 ITOT at $84.00 per share. Commission: $10.

(a) Compute the realized P/L for 2024 and 2025 using the separate expense method (commissions to Expenses:Commissions).

(b) Compute the realized P/L for 2024 and 2025 using the folded cost basis method.

(c) Write the Beancount transactions for method (b). Show all postings.

(d) The user's tax advisor says the 2024 P/L is different in methods (a) and (b). Which is correct and why?

Solution

(a) Separate expense method:

2024:

Proceeds:       40 × $82.00 = $3,280.00
Cost of goods:  40 × $80.00 = $3,200.00
Gross gain:                     $80.00
Commissions:    $10 (buy) + $10 (sell) = $20.00   ← full buy commission wrongly attributed
Net P/L 2024:                   $60.00

2025:

Proceeds:       60 × $84.00 = $5,040.00
Cost of goods:  60 × $80.00 = $4,800.00
Gross gain:                    $240.00
Commission:                    -$10.00 (sell)
Net P/L 2025:                  $230.00

Total across both years: $60 + $230 = $290.

(b) Folded cost basis method:

Per-share cost basis: ($80.00 × 100 + $10) / 100 = $80.10

2024:

Proceeds:    40 × $82.00 = $3,280.00
Cost basis:  40 × $80.10 = $3,204.00
Gross gain:               $76.00
Sale commission:           -$10.00
Net P/L 2024:              $66.00

2025:

Proceeds:    60 × $84.00 = $5,040.00
Cost basis:  60 × $80.10 = $4,806.00
Gross gain:               $234.00
Sale commission:           -$10.00
Net P/L 2025:              $224.00

Total: $66 + $224 = $290. Same total, different year-by-year allocation.

(c) Beancount transactions (method b):

2024-01-01 * "Buy 100 ITOT"
  Assets:Brokerage:ITOT   100 ITOT {80.10 USD}
  Assets:Brokerage:Cash  -8010.00 USD

2024-11-01 * "Sell 40 ITOT"
  Assets:Brokerage:ITOT   -40 ITOT {80.10 USD} @ 82.00 USD
  Assets:Brokerage:Cash   3270.05 USD          ; 3280 - 9.95 sale commission
  Expenses:Commissions       9.95 USD
  Income:CapGains                              ; auto-filled: -76.00 USD

2025-02-01 * "Sell 60 ITOT"
  Assets:Brokerage:ITOT   -60 ITOT {80.10 USD} @ 84.00 USD
  Assets:Brokerage:Cash   5020.05 USD          ; 5040 - 9.95
  Expenses:Commissions       9.95 USD
  Income:CapGains                              ; auto-filled: -234.00 USD

Note: the sale commission is still booked to Expenses:Commissions because it is a period expense of the year in which it occurs (not an acquisition cost). The acquisition commission ($10) was fully folded into cost basis at purchase. The two commissions are treated differently.

(d) Method (b) is correct:

The Internal Revenue Service (and most tax authorities) require that acquisition costs be capitalized into the asset's cost basis and pro-rated to the cost of goods sold. Booking the full acquisition commission to the year of sale (method a) is an overstatement of that year's expenses and an understatement of the following year's. The total tax liability is the same in aggregate, but the year-by-year timing differs — which matters for quarterly estimated taxes and can create underpayment penalties.


Exercise 8-B: Corporate action lifecycle

You hold the following in Assets:Brokerage:ACME:

  • 100 ACME {50.00 USD, 2023-03-01}

On 2024-06-01, ACME does a 3-for-2 stock split (every 2 shares become 3). On 2024-09-15, ACME pays a $0.50/share cash dividend. On 2025-01-10, you sell all shares at $40.00.

(a) Write the Beancount transaction for the stock split. Preserve the original acquisition date.

(b) After the split, what is your position? What is the per-share cost basis?

(c) Write the dividend transaction.

(d) Write the sale transaction. Compute realized P/L.

(e) Is this a long-term or short-term gain under US rules (held >1 year)?

Solution

(a) Stock split (3-for-2):

100 shares become 150 shares. Cost basis per share: $50.00 × (2/3) = $33.33 (rounded; exact: 50 × 100 / 150 = $33.3333...).

2024-06-01 * "3-for-2 stock split"
  Assets:Brokerage:ACME  -100 ACME {50.00 USD, 2023-03-01}
  Assets:Brokerage:ACME   150 ACME {33.3333 USD, 2023-03-01}

The total cost basis is preserved: 150 × $33.3333 = $4,999.99 ≈ $5,000 (rounding artifact — in practice use full precision or adjust one unit to absorb the rounding).

(b) Position after split:

150 ACME {33.3333 USD, 2023-03-01}

Per-share cost basis: $33.3333 USD. Acquisition date: 2023-03-01 (preserved).

(c) Cash dividend:

150 shares × $0.50 = $75.00

2024-09-15 * "ACME quarterly dividend"
  Assets:Brokerage:Cash    75.00 USD
  Income:Dividends        -75.00 USD

Dividends don't affect the cost basis of the shares. They are income in the year received.

(d) Sale:

2025-01-10 * "Sell all ACME"
  Assets:Brokerage:ACME  -150 ACME {33.3333 USD, 2023-03-01} @ 40.00 USD
  Assets:Brokerage:Cash   6000.00 USD
  Income:CapGains                   ; auto-filled

Realized P/L:

Proceeds:    150 × $40.00    = $6,000.00
Cost basis:  150 × $33.3333  = $4,999.99
Realized gain:               = $1,000.01

Income:CapGains is auto-filled as -1000.01 USD (credit = gain).

(e) Long-term or short-term?

Acquisition date: 2023-03-01. Sale date: 2025-01-10. Holding period: ~22 months. This is well over 12 months → long-term capital gain. The split date (2024-06-01) is irrelevant to the holding period — splits don't reset the acquisition clock. This is why preserving the original date on the split transaction matters.


Exercise 8-C: TigerBeetle income statement for a brokerage

You are building a brokerage on TigerBeetle. Every user has the following accounts (all ledger=840, USD):

user_cash      — cash balance (debit-normal, credit_normal=false)
user_positions — aggregate asset value in shares (tracked externally)
realized_pnl   — capital gains account (credit-normal)
dividend_pnl   — dividend income account (credit-normal)
commission_exp — commission expense account (debit-normal)

A user buys 10 ACME at $50 (commission $5), then sells 10 ACME at $70 (commission $5), then receives a $15 dividend.

(a) Write the TigerBeetle Transfer sequence for the purchase. The cash debit goes to user_cash; a cost-basis credit goes to a cost_basis_clearing account.

(b) Write the Transfer sequence for the sale. Show how realized P/L is booked.

(c) Write the dividend Transfer.

(d) After all operations, what is realized_pnl.credits_posted − realized_pnl.debits_posted? What does this number represent?

(e) The user wants their YTD income statement. Write pseudocode using TigerBeetle API calls.

Solution

(a) Purchase ($500 + $5 commission):

T₁: debit=user_cash, credit=cost_basis_clearing, amount=50000
    code=STOCK_PURCHASE, flags.linked=true
    user_data_128=order_id

T₂: debit=commission_exp, credit=user_cash, amount=500
    code=COMMISSION, flags.linked=false

Wait — commission_exp is debit-normal (expense). Debiting it increases the expense balance. We debit user_cash to reduce the user's cash and credit... actually, we need to think about this as a fintech operator:

The user pays cash → goes to an internal commission account:

T₁: debit=user_cash, credit=cost_basis_clearing, amount=50000, linked=true
T₂: debit=user_cash, credit=commission_revenue, amount=500, linked=false

cost_basis_clearing holds the purchase cost basis ($500) until the shares are sold. commission_revenue is the operator's income.

(b) Sale ($700 proceeds, $5 commission, $200 gain):

The user receives $700 in cash. The cost basis ($500) is released from clearing. The $200 difference is booked to realized_pnl.

T₃: debit=cost_basis_clearing, credit=user_cash, amount=50000, linked=true
    ; returns cost basis from clearing → user cash
    code=STOCK_SALE_COST_BASIS

T₄: debit=sale_proceeds_transit, credit=user_cash, amount=20000, linked=true
    ; books the $200 gain — proceeds above cost
    code=STOCK_SALE_GAIN

T₅: debit=sale_proceeds_transit, credit=realized_pnl, amount=20000, linked=true
    ; credits the gain to the P/L account
    code=REALIZED_GAIN

T₆: debit=user_cash, credit=commission_revenue, amount=500, linked=false
    ; commission on sale
    code=COMMISSION

Simplified real-world pattern: broker receives gross proceeds from counterparty, nets the cost basis, books gain:

T₃: debit=user_cash, credit=realized_pnl, amount=20000, linked=true
    ; book gain: user gets $200 extra above cost
T₄: debit=user_cash, credit=commission_revenue, amount=500, linked=false
    ; commission deducted

The exact pattern depends on whether the operator holds positions or routes through a custodian. The key invariant: realized_pnl.credits_posted accumulates all gains; realized_pnl.debits_posted accumulates all losses.

(c) Dividend:

T₇: debit=dividend_transit, credit=user_cash, amount=1500
    code=DIVIDEND
    user_data_128=dividend_payment_id

T₈: debit=dividend_expense, credit=dividend_transit, amount=1500
    code=DIVIDEND_BOOKED

Or in a simpler model where the operator funds dividends from its own account:

T₇: debit=operator_reserve, credit=user_cash, amount=1500, linked=true
T₈: debit=dividend_pnl_expense, credit=operator_reserve, amount=1500, linked=false

(d) realized_pnl balance:

credits_posted = 20000  ($200 gain from sale)
debits_posted  = 0      (no losses)
net balance    = credits_posted - debits_posted = 20000

This represents $200.00 in realized capital gains for the period. In a credit-normal account, credits_posted − debits_posted is the positive balance. A net negative would indicate net losses.

(e) YTD income statement pseudocode:

ytd_start = datetime(2025, 1, 1).timestamp_ns()
ytd_end   = now_ns()

# Realized P/L
pnl_transfers = client.get_account_transfers(
    account_id=realized_pnl_id,
    timestamp_min=ytd_start,
    timestamp_max=ytd_end,
)
realized_gain = sum(t.amount for t in pnl_transfers if t.credit_account_id == realized_pnl_id)
realized_loss = sum(t.amount for t in pnl_transfers if t.debit_account_id  == realized_pnl_id)
net_realized  = realized_gain - realized_loss  # in cents

# Dividend income
div_transfers = client.get_account_transfers(
    account_id=dividend_pnl_id,
    timestamp_min=ytd_start,
    timestamp_max=ytd_end,
)
dividend_income = sum(t.amount for t in div_transfers if t.credit_account_id == dividend_pnl_id)

# Commission expense
comm_transfers = client.get_account_transfers(
    account_id=commission_exp_id,
    timestamp_min=ytd_start,
    timestamp_max=ytd_end,
)
commissions_paid = sum(t.amount for t in comm_transfers if t.debit_account_id == commission_exp_id)

income_statement = {
    "realized_capital_gains": net_realized / 100,       # USD
    "dividend_income":        dividend_income / 100,
    "commissions":            -commissions_paid / 100,
    "net_income":             (net_realized + dividend_income - commissions_paid) / 100,
}

Note: get_account_balances gives lifetime totals; get_account_transfers filtered by timestamp is necessary for period-specific reporting. TigerBeetle's transfer timestamps are nanosecond-precision monotonic values — use them for filtering, not application-layer created_at columns.


Source Reading

  • beancountdocs/docs/trading_with_beancount.md — What is Profit and Loss, Realized and Unrealized P/L, Trade Lots, Booking Methods, Dated lots, Commissions, Stock Splits, Dividends
  • beancountdocs/docs/how_inventories_work.md — Inventory reduction mechanics, booking method disambiguation
  • beancountdocs/docs/beancount_query_language.md — Using bean-query for P/L reports, COST() aggregation function
  • tigerbeetledocs/coding/financial-accounting/index.html — Types of Accounts, Income/Expense account model
  • tigerbeetledocs/coding/data-modeling/index.html — Account struct, get_account_transfers for period queries

Interlude: After Module 8

Synthesis question: A hedge fund uses LIFO booking for internal P/L reporting (to management) but is legally required to use FIFO for tax reporting (to the IRS). Can a single Beancount file serve both simultaneously? If not, what is the minimum architecture that does?

Discussion

The core problem: booking method is a property of the account, applied at the time of lot reduction. When you record a sale, Beancount matches it against specific lots based on the configured method. The resulting Income:CapGains posting reflects the P/L of those specific lots. There is no way to simultaneously reduce Lot A (LIFO) and Lot B (FIFO) — a sale reduces exactly one set of lots.

What a single file cannot do: a single Assets:Brokerage:ACME account with a single booking method cannot produce two different P/L numbers for the same sale. The lot selection is deterministic given the method.

Minimum architecture: two parallel account trees

; --- LIFO tree (management reporting) ---
2024-01-10 open Assets:LIFO:Brokerage:ACME
2024-01-10 open Income:LIFO:CapGains

; --- FIFO tree (tax reporting) ---
2024-01-10 open Assets:FIFO:Brokerage:ACME
2024-01-10 open Income:FIFO:CapGains

Every purchase is entered twice — once into each tree at the same cost basis and date:

2024-01-10 * "Buy ACME"
  Assets:LIFO:Brokerage:ACME   100 ACME {50.00 USD, 2024-01-10}
  Assets:FIFO:Brokerage:ACME   100 ACME {50.00 USD, 2024-01-10}
  Assets:Cash                 -10000.00 USD
  Assets:Cash                 -10000.00 USD   ; ← this double-counts cash!

Problem: the cash account is shared between both trees. Double-entry holds in each tree, but the cross-tree transaction doesn't balance against a single cash account.

Solution: use a single cash account, mirror only the position and income legs:

2024-01-10 * "Buy ACME"
  Assets:LIFO:Brokerage:ACME   100 ACME {50.00 USD, 2024-01-10}
  Assets:FIFO:Brokerage:ACME   100 ACME {50.00 USD, 2024-01-10}
  Assets:Cash                 -10000.00 USD
  Equity:MirrorOffset          10000.00 USD  ; absorbs the double-position cost

This is ugly. In practice, two separate Beancount files are cleaner — one for LIFO (management), one for FIFO (tax). They share purchase data but diverge at every sale transaction.

Can Beancount support this natively? No. Beancount's booking method is a per-account option applied globally — there is no "report this sale under two methods simultaneously" directive. The fund's accounting team must maintain two separate ledgers and reconcile them manually (or via script) at year-end. The scripts that generate the two files from a shared trade blotter are the real engineering problem.

TigerBeetle angle: TigerBeetle has no booking method. You maintain two separate cost_basis_clearing accounts (one LIFO-managed, one FIFO-managed) and write application code that, for each sale, looks up lots under the appropriate method and issues linked transfers to the correct clearing account. The two clearing accounts diverge in balance over time — their difference at year-end is the cumulative difference in realized P/L between the two methods.

Key takeaway: dual-method accounting is not a single-ledger problem. It is a two-ledger problem with a shared trade blotter and diverging lot-selection logic. Any system claiming to do both simultaneously with a single account tree is computing an approximation, not a precise dual-method ledger.

Fintech Patterns: Idempotency, Correcting Entries, Rate Limiting, Balance-Conditional Transfers

Concept

Four production patterns, each a direct consequence of invariants from earlier modules. None are optional in a real fintech system.


Pattern 1: Idempotency via Client-Generated IDs

The client — not the API server — generates the transfer id before any network call, and persists it to local storage before submission.

1. User initiates transfer
2. Client generates id (e.g., UUIDv4 or ULID)
3. Client persists id to local storage
4. Client submits transfer to API
5. API includes transfer in create_transfers request
6. TigerBeetle creates it once and only once

On retry with the same id: TigerBeetle returns exists (success). On first submission: returns ok. The API server never needs to check — TigerBeetle's deduplication is structural. Critical: if the API generates the id, a client restart before receiving the response will result in a second submission with a new id, creating a duplicate transfer.


Pattern 2: Correcting Entries — Always Add, Never Modify

Transfers are immutable. A "correction" is a new Transfer in the opposite direction, linked to the original via user_data_128.

Original:  T1  debit=A  credit=B  amount=100  code=1001  user_data_128=<invoice_id>
Error discovered.
Reversal:  T2  debit=B  credit=A  amount=100  code=1002  user_data_128=<invoice_id>
Correct:   T3  debit=A  credit=B  amount=90   code=1001  user_data_128=<invoice_id>

Use a dedicated code value to identify corrections (e.g., 1002 = reversal, 1003 = adjustment). The full audit trail is: T1 happened, T2 reversed it, T3 is the correct amount. A reporting layer can "downsample" to show only the net result; the raw ledger preserves all three. A correction can itself be wrong — correct that with another transfer. The stack never needs to be popped destructively.


Pattern 3: Rate Limiting via Leaky Bucket

TigerBeetle can account for non-financial resources. The leaky bucket pattern:

Setup (once per user, per resource type):

Ledger:  RequestRate  (separate from any financial ledger)
Accounts:
  - operator_ratelimit  (no flags — unlimited)
  - user_ratelimit      (flags: debits_must_not_exceed_credits)

Seed transfer:
  debit=operator_ratelimit  credit=user_ratelimit  amount=10
  → user_ratelimit now has credits_posted=10, balance=10

Per request:

Pending transfer:
  debit=user_ratelimit  credit=operator_ratelimit  amount=1  timeout=60  flags=pending
  • If debits_pending would exceed credits_posted → TigerBeetle rejects → request blocked
  • If transfer succeeds → request allowed → pending expires after 60s → balance automatically restored
  • No explicit "reset" needed — expiry is the reset

Transfer amount limiting (two ledgers, linked):

T1 (rate-limit ledger):  debit=user_ratelimit  credit=op_ratelimit  amount=X
                         timeout=86400  flags=pending|linked
T2 (USD ledger):         debit=user_usd  credit=dest_usd  amount=X
                         flags=(none)

T1 and T2 are linked. If T1 fails (daily limit exhausted), T2 also fails atomically. No partial state.


Pattern 4: Balance-Conditional Transfers

Execute a transfer only if the source account has at least a threshold balance — atomically, without a separate read-then-write.

The naive approach is unsafe:

balance = lookup_accounts(source_id).balance   ← snapshot
if balance >= threshold:                        ← not atomic with the next line
    create_transfers(...)                       ← balance may have changed

The correct approach uses 3 linked transfers and a control account:

Source has credit balance (Liability/Income):
  T1: debit=source      credit=control    amount=threshold  flags=linked|pending
  T2: void_pending_transfer T1                              flags=linked|void_pending_transfer
  T3: debit=source      credit=dest       amount=transfer_amount

Source has debit balance (Asset/Expense):
  T1: debit=control     credit=source     amount=threshold  flags=linked|pending
  T2: void_pending_transfer T1                              flags=linked|void_pending_transfer
  T3: debit=dest        credit=source     amount=transfer_amount

Mechanism: T1 attempts to move threshold to the control account. If source lacks the balance, T1 fails (balance limit flag rejects it) → T2 and T3 also fail → nothing happens. If T1 succeeds, T2 immediately voids it (returning funds to source), and T3 executes the actual transfer. The control account always nets to zero — it is purely a probe mechanism.


Socratic Dialogue

Q1: The API server generates transfer IDs. A user clicks "Send $50" on their phone. The API call reaches TigerBeetle and succeeds, but the response is lost in transit. The user's app retries. What happens?

Answer

The API server generates a new id on the retry (it has no memory of the failed response). TigerBeetle sees a Transfer with a different id, treats it as a new request, and creates a second transfer. The user's account is debited $100. The bank statement shows two $50 transfers.

This is the fundamental argument for client-side ID generation. The client is the only party that survives all failure modes with persistent local state. The API server is stateless between requests. TigerBeetle is stateful but only knows what it was told. Only the client can supply the same ID across a retry boundary.

Fix: move ID generation to the client before any network boundary. Persist to local storage before the first HTTP request. Use the same ID on every retry. TigerBeetle's exists response is indistinguishable from ok from the client's perspective — both mean "the transfer is in the ledger."


Q2: A correcting transfer reverses the original by debiting B and crediting A for the same amount. Trial balance still holds. But what does the audit log now show? Why is this preferable to a database UPDATE?

Answer

The audit log shows three facts in temporal order: (1) on date D1, $100 moved from A to B (the error), (2) on date D2, $100 moved from B to A (the reversal), (3) on date D3, $90 moved from A to B (the correct amount). Each event has a timestamp, an immutable ID, and a code field identifying its semantic type.

A database UPDATE accounts SET balance = balance - 10 destroys the error. The audit log becomes: "on D1, $90 moved from A to B." You can no longer tell that $100 was originally recorded and corrected, when the error was discovered, or who authorized the correction. For regulated financial systems, this is not merely bad practice — it may constitute a records violation.

TigerBeetle's immutability is not a technical limitation; it is a deliberate choice to make corrections observable at the finest resolution. A reporting layer can show "net $90 moved from A to B," but the raw ledger preserves the full correction chain.


Q3: The rate-limit account for a user has credits_posted=10, debits_pending=8. A request comes in. Does it succeed? What is the available balance at the moment of decision?

Answer

Available balance = credits_posted - debits_pending = 10 − 8 = 2. The incoming request requires debits_pending += 1, which would make debits_pending = 9. Since credits_posted (10) >= debits_pending (9), the transfer succeeds. The user has 1 unit of rate-limit budget remaining.

The next request would attempt debits_pending = 10. credits_posted (10) >= debits_pending (10) — still succeeds. The one after would attempt debits_pending = 11 > credits_posted = 10 — rejected by debits_must_not_exceed_credits. As the oldest pending transfers expire (after 60s), debits_pending decreases and the budget is replenished automatically.

Key insight: TigerBeetle's balance arithmetic is used here to enforce a temporal resource budget, not a financial balance. The accounting identity is the enforcement mechanism.


Q4: Why is lookup_accounts + create_transfers not an atomic balance check? What failure mode does it introduce in a concurrent system?

Answer

lookup_accounts returns a snapshot of the account's balance at a single point in time. Between that read and the subsequent create_transfers call, other clients may submit transfers that modify the balance. TigerBeetle processes requests serially within a batch but operates on multiple concurrent client connections. The window between the two requests is not protected by any lock or transaction.

Failure mode: two clients both read balance=500. Both determine the threshold is met. Both submit their transfers. Both succeed — TigerBeetle processes them in arrival order. After both complete, balance=−500 (if the flag is not set) or the second one fails with a surprising rejection (if the flag is set but the client expected success based on its earlier read). This is a classic TOCTOU (time-of-check time-of-use) race.

The 3-transfer pattern eliminates the window by making the balance check and the transfer a single atomic operation within TigerBeetle's serial processing loop.


Q5: A user submits a correcting transfer using user_data_128 to reference the original transfer's ID. Three weeks later, a regulator asks: "Show me all corrections made to this account in Q3." How do you query for that?

Answer
# Get all transfers on the account
transfers = client.get_account_transfers(
    account_id=account_id,
    timestamp_min=q3_start_ns,
    timestamp_max=q3_end_ns,
)

# Filter for correction codes
CORRECTION_CODES = {1002, 1003}  # reversal, adjustment
corrections = [t for t in transfers if t.code in CORRECTION_CODES]

# For each correction, look up the original
for c in corrections:
    original_id = c.user_data_128  # FK to original transfer
    original = client.lookup_transfers([original_id])
    print(f"Correction {c.id} reversed original {original_id}")

This works because user_data_128 is a 128-bit FK to the application DB (or directly to the original transfer's id). The code field discriminates correction types. get_account_transfers with timestamp range returns only the relevant period.

Note: TigerBeetle does not enforce the user_data_128 → original transfer relationship. Your application must establish the convention. If the correcting transfer's user_data_128 is left unset, you lose the linkage. The discipline is at the application layer; TigerBeetle only provides the storage.


Q6: The transfer-amount-limiting pattern uses timeout=86400 (one day) on the rate-limit pending transfer. A user transfers $999 at 11:58 PM. At 11:59 PM (one minute later), can they transfer another $999 if the daily limit is $1000?

Answer

No. The pending transfer from 11:58 PM expires 86400 seconds later — at 11:58 PM the next day. At 11:59 PM, that pending has not expired. debits_pending is still 999. The second $999 transfer would attempt debits_pending = 1998 > credits_posted = 1000 — rejected.

There is no clock-based reset in this design. There is no "midnight UTC reset." Each pending transfer expires relative to its own timestamp + timeout. If you need a hard calendar-day reset, you need a separate mechanism: a cron job that voids all outstanding pending transfers at midnight, or a different pattern using a sentinel account that is zeroed and re-seeded on a schedule.

The leaky bucket pattern is a rolling window, not a fixed window. The $999 budget refills 86400 seconds after each individual transfer, not at a fixed wall-clock time.


Q7: In the balance-conditional transfer pattern, what is the net effect on the control account across a successful 3-transfer batch? Across a failed batch?

Answer

Successful batch: T1 moves threshold from source to control (control.credits_posted += threshold). T2 voids T1 — the pending is cancelled, so control.credits_posted is decremented back. T3 is an independent transfer between source and dest; control is not involved. Net effect on control: zero.

Failed batch: T1 fails (source balance insufficient) → T2 and T3 do not execute (linked). Net effect on control: zero. Control is never touched.

In all outcomes, control ends at exactly the same balance it started with. It is a probe, not a participant. You can use a single shared control account for all balance-conditional operations on a ledger — it will always net to zero.


Exercises

Exercise 9-A: Idempotency Failure Analysis

Your mobile app generates transfer IDs server-side. You observe that approximately 0.3% of transfers appear in duplicate in the ledger — always in pairs, always within 30 seconds of each other, always for the same amount. Describe the exact failure mode, the sequence of events that produces each duplicate, and the minimal code change that eliminates it without requiring any server-side changes.

Solution

Failure mode: The server generates a new UUID on each request. The 30-second window is the app's retry timeout. Sequence:

  1. User taps "Pay"
  2. App sends POST /transfer to API server
  3. API server generates id = uuid_v4(), calls TigerBeetle
  4. TigerBeetle creates transfer, responds ok
  5. API server responds 200 to app — response lost in transit (TCP timeout, airplane mode, etc.)
  6. App times out after 30s, retries POST /transfer
  7. API server generates id = uuid_v4()different UUID
  8. TigerBeetle sees a new ID, creates a second transfer

The 0.3% rate matches typical mobile network interruption rates for time-sensitive responses.

Minimal fix — client only (requires server to accept client-provided ID):

async function sendPayment(amount, dest) {
  // Generate and persist ID BEFORE any network call
  let transferId = localStorage.getItem('pending_transfer_id');
  if (!transferId) {
    transferId = crypto.randomUUID();
    localStorage.setItem('pending_transfer_id', transferId);
  }

  try {
    const response = await api.post('/transfer', {
      id: transferId,  // client sends its own ID
      amount,
      dest,
    });
    localStorage.removeItem('pending_transfer_id');  // clear on confirmed success
    return response;
  } catch (e) {
    // ID remains in localStorage — next retry uses the same ID
    throw e;
  }
}

On retry, TigerBeetle receives the same id, returns exists, the API returns success, local storage is cleared. No duplicate created.

Edge case: if local storage is cleared (app reinstall), the pending transfer in TigerBeetle is orphaned. A server-side reconciliation job must detect transfers with no app-side confirmation. Client-side ID generation is necessary but not sufficient for full reliability — the persisted ID must survive the app's lifecycle.

If the server cannot be modified to accept a client-provided ID, the fix is impossible without server changes. This is why "API generates the ID" is architecturally broken at the transport layer.


Exercise 9-B: Correcting a Batch of Linked Transfers

A payroll run processed 500 employees. Due to a bug, the amount field was multiplied by 10 — every employee received 10× their salary. The transfers used code=2001 and stored the payroll batch ID in user_data_64. You need to: (1) reverse all 500 transfers, (2) issue correct amounts, (3) ensure the entire correction is atomic per-employee (not per-batch). Design the TigerBeetle transfer sequence and identify the key risks.

Solution

Per-employee atomicity — for each employee, issue two linked transfers:

T_reversal:   debit=employee_checking  credit=payroll_account
              amount=erroneous_amount
              code=2002                           ; reversal code
              user_data_128=<original_transfer_id>
              user_data_64=<payroll_batch_id>
              flags=linked

T_correct:    debit=payroll_account  credit=employee_checking
              amount=correct_amount
              code=2001
              user_data_64=<payroll_batch_id>
              flags=(none)

These two are linked per employee. If the reversal fails (employee spent the overpayment — balance insufficient), the correction also fails for that employee. Handle those employees separately via a debt recovery process.

Submission: all 500 pairs (1000 transfers) in one or two batches. Pairs are linked within an employee but not across employees — one employee's failure does not roll back the others.

Key risks:

  1. Partial reversal impossibility: TigerBeetle does not support reversing more than the current balance if debits_must_not_exceed_credits is set. Policy decision: (a) reverse only available balance and track residual debt in your app DB, (b) use a recovery account that permits going negative.

  2. Idempotency of the correction run: if the correction script crashes and retries, each reversal+correction pair must use stable pre-generated IDs. Store all 1000 IDs before starting. On retry, TigerBeetle returns exists for already-processed pairs.

  3. Audit trail: query WHERE code=2002 AND user_data_64=<batch_id> to retrieve all reversals for this batch. Use timestamp to distinguish original errors (code=2001, earlier timestamps) from corrections (code=2001, later timestamps). Both use the same code — the timestamp is the discriminator.

  4. Notification race: the reversal debit may trigger real-time fraud alerts. Coordinate with the notification system before submitting.


Exercise 9-C: Capstone — Robinhood-Style Brokerage Account Schema

Design the complete TigerBeetle account schema for a retail brokerage. For each user action (deposit, buy stock, sell stock, withdraw, receive dividend), write the Transfer sequence. Identify every point where balance invariants must be enforced via account flags.

Solution

Account Schema

One ledger per currency/commodity. Each user needs accounts on each ledger they participate in.

LEDGER 1: USD (id=1)
  operator_usd            — omnibus cash pool (no flags)
  user_{uid}_cash         — user's settled cash
                            flags: debits_must_not_exceed_credits
  user_{uid}_unsettled    — sale proceeds in T+2 transit (no flags)
  tax_withholding         — operator account for withheld taxes (no flags)
  commission_pool         — operator revenue (no flags)
  control_usd             — balance-conditional probe account (no flags, always nets to zero)

LEDGER 2: AAPL (id=2, unit = 1 share × 10^6 for fractional)
  user_{uid}_aapl         — user's AAPL position
                            flags: debits_must_not_exceed_credits
  operator_aapl           — omnibus position account (no flags)

LEDGER 3: TRADE_RATE (id=3, non-financial)
  operator_ratelimit      — no flags
  user_{uid}_ratelimit    — flags: debits_must_not_exceed_credits

Deposit $1000:

T1: debit=operator_usd  credit=user_{uid}_cash  amount=100000
    code=DEPOSIT  user_data_128=<bank_transfer_id>

Single transfer. Balance constraint not relevant here — operator is unconstrained.


Buy 10 AAPL at $180 ($1800 + $4.99 commission):

; Reserve cash — pending authorization
T1: debit=user_{uid}_cash  credit=operator_usd  amount=180499
    flags=pending  timeout=300  code=ORDER_RESERVE
    user_data_128=<order_id>

; On fill — post the pending and deliver shares atomically
T2: post_pending_transfer T1  amount=180499
    flags=post_pending_transfer|linked
T3: debit=operator_aapl  credit=user_{uid}_aapl  amount=10_000000
    code=BUY_SHARES|linked  user_data_128=<order_id>
T4: debit=operator_usd  credit=commission_pool  amount=499
    code=COMMISSION

Invariant: user_{uid}_cash.debits_must_not_exceed_credits blocks the pending if cash is insufficient. T1 (pending) reserves cash — concurrent orders cannot double-spend. T2/T3 are linked — no scenario where cash is taken but shares not delivered.


Sell 5 AAPL at $185 ($925 − $4.99 commission):

; Reserve shares — pending
T1: debit=user_{uid}_aapl  credit=operator_aapl  amount=5_000000
    flags=pending  timeout=300  code=ORDER_RESERVE
    user_data_128=<order_id>

; On fill
T2: post_pending_transfer T1  amount=5_000000
    flags=post_pending_transfer|linked
T3: debit=operator_usd  credit=user_{uid}_unsettled  amount=92001
    code=SALE_UNSETTLED|linked  user_data_128=<order_id>
T4: debit=operator_usd  credit=commission_pool  amount=499
    code=COMMISSION

; T+2 settlement
T5: debit=user_{uid}_unsettled  credit=user_{uid}_cash  amount=92001
    code=SALE_SETTLED  user_data_128=<order_id>

Invariant: user_{uid}_aapl.debits_must_not_exceed_credits prevents overselling. T3 credits unsettled not cash — user cannot withdraw proceeds before T+2.


Withdraw $500:

; Balance-conditional: atomically verify cash >= $500 before executing
T1: debit=user_{uid}_cash  credit=control_usd  amount=50000
    flags=linked|pending
T2: void_pending_transfer T1
    flags=linked|void_pending_transfer
T3: debit=user_{uid}_cash  credit=operator_usd  amount=50000
    code=WITHDRAWAL  user_data_128=<bank_transfer_id>

Without the 3-transfer pattern, a concurrent buy order's pending debit could reduce available cash between a lookup_accounts read and the withdrawal transfer — TOCTOU race. The 3-transfer pattern makes the balance check and the debit atomic.


Receive Dividend ($0.25/share × 100 shares = $25, 30% withholding):

T1: debit=operator_usd  credit=user_{uid}_cash  amount=1750
    flags=linked  code=DIVIDEND  user_data_128=<dividend_event_id>
T2: debit=operator_usd  credit=tax_withholding  amount=750
    code=DIVIDEND_WITHHOLDING  user_data_128=<dividend_event_id>

T1 and T2 are linked — dividend credit and withholding deduction are atomic. No state where user receives gross dividend without withholding.


Complete Invariant Map

AccountFlagPrevents
user_cashdebits_must_not_exceed_creditsOverdraft, buying with non-existent cash
user_aapldebits_must_not_exceed_creditsShort selling (naked)
user_ratelimitdebits_must_not_exceed_creditsOrder rate abuse
user_unsettled(none)Settlement account, may fluctuate
operator_*(none)Operator accounts are unconstrained by design
control_usd(none)Probe account, always nets to zero

What TigerBeetle cannot enforce without application logic:

  • Pattern-day-trader rules (≥4 round trips in 5 days): requires querying get_account_transfers and counting in application code
  • Wash sale detection: cross-account, cross-user analysis — no TigerBeetle primitive
  • PDT margin requirements: account classification metadata lives in your application DB, not TigerBeetle

Source Reading

  • tigerbeetledocs/coding/reliable-transaction-submission/index.html — The App or Browser Should Generate the ID, Handling Network Failures, Handling Client Software Restarts
  • tigerbeetledocs/coding/recipes/correcting-transfers/index.html — Always Add More Transfers, using Transfer.code and Transfer.user_data_128 to link corrections to originals
  • tigerbeetledocs/coding/recipes/rate-limiting/index.html — Mechanism, Request Rate Limiting, Bandwidth Limiting, Transfer Amount Limiting
  • tigerbeetledocs/coding/recipes/balance-conditional-transfers/index.html — Preconditions, Executing a Balance-Conditional Transfer, Understanding the Mechanism
  • tigerbeetledocs/concepts/safety/index.html — immutability guarantees underpinning correcting entries
  • tigerbeetledocs/coding/two-phase-transfers/index.html — pending/post/void/expire mechanics underlying rate limiting and order reservation

End of Curriculum

You have covered:

ModuleCore Invariant Learned
0sum(all_postings) == 0 always
1account = accumulator, posting = signed delta, transaction = zero-sum set
25 account types are a semantic type system; accounting equation is a corollary of M1
3TigerBeetle's 2-account Transfer vs Beancount's N-posting transaction; flags.linked for atomicity
4TigerBeetle's balance arithmetic; ledger = currency partition
5Detective vs preventive consistency; immutable history; bisection for discrepancy detection
6Authorization ≠ settlement; pending → posted/voided/expired state machine
7Conservation within commodity, not across; cross-currency = two linked transfers via liquidity account
8Realized vs unrealized P/L; booking method determines tax liability; lot tracking is application-layer in TigerBeetle
9Client-generated IDs; correcting entries; leaky bucket rate limiting; balance-conditional transfers

The through-line: every pattern in fintech is an application of sum(all_postings) == 0, enforced either at submission time (TigerBeetle flags) or at audit time (Beancount balance assertions). All complexity is bookkeeping.

About

This book was written by Egemen Göl, a software engineer building with backend technologies, Zero-Knowledge, Web3, and GenAI.