Skip to content
owen/richards
All writing
8 min read

FieldDay needs an append-only money ledger. The exciting answer was a dedicated financial database; the right answer was Postgres — until the throughput says otherwise. With the napkin math, the schema, and the u128 bug that nearly misrouted money.

FieldDay, the platform I'm building solo, moves money: participants pay organizations, organizations pay out, and sponsors fund pools that subsidize specific activities. Anything that touches money needs a ledger — append-only, reconcilable, auditable. So I did the responsible thing and spent a weekend convincing myself I needed TigerBeetle, the purpose-built financial database everyone in my feed was excited about.

I didn't. Here's the evaluation, the schema I shipped instead, and the bug that taught me to respect 128-bit integers.

The job is append-only money, not speed

A ledger has a small, strict job. Every money movement is a row that is written once and never changed — corrections are new compensating rows, not UPDATEs. Every row balances: the amount a participant paid equals the operator's cut plus the platform fee plus the net. And at any point you have to be able to prove the books are consistent without trusting the application that wrote them.

None of that is about throughput. It's about discipline — the schema refusing to let you mutate history, and a reconciliation process that can independently recompute the truth. TigerBeetle is extraordinary at one thing — millions of double-entry transfers per second with two-phase transfers and deterministic recovery — and FieldDay, pre-launch, does not have that problem. The question was whether it would have it soon enough to justify operating a second stateful database now.

The napkin math that ended the TigerBeetle case

I wrote it down, because "we might need scale" is how you end up operating things you don't. FieldDay's Phase-1 through Phase-3 plan tops out around 3,000 transactions a month — call it 0.001 transactions per second on average, with bursts during registration windows that are still trivial. TigerBeetle starts to matter — over Postgres, on a workload like this — somewhere north of 5,000 sustained transfers per second. That's the load of roughly 150,000 active organizations.

So the gap between what I have and what TigerBeetle is for is about six orders of magnitude. Adopting it now would mean a second database to provision, monitor, back up, and reason about during an incident, in exchange for performance I cannot measure at this scale. That's the textbook shape of resume-driven design — the kind of thing I'd flag in someone else's review. The evaluation I wrote (now about 800 lines) ends with "not yet, and here's the number that will change my mind."

FieldDay today

~0.001 tx/s · 3K/mo

TigerBeetle pays off

~5,000 tx/s · 150K+ orgs

≈ 6 orders of magnitude
0.0010.010.11101001K10K

transactions / second · log scale

The workload is six orders of magnitude below where the specialized database earns its cost.

What I built in Postgres

The ledger is one append-only table, partitioned by time, with the constraints that make "append-only" true rather than aspirational.

commerce.transactions (abridged)
-- Append-only: no UPDATE, no DELETE. Corrections are new, compensating rows.
-- Quarterly RANGE partitions keep the hot set small and pruning cheap.
CREATE TABLE commerce.transactions (
  id            uuid         NOT NULL,
  created_at    timestamptz  NOT NULL,
  amount_cents  bigint       NOT NULL,        -- money is bigint, never float
  facility_cut_cents  bigint NOT NULL,
  platform_fee_cents  bigint NOT NULL,
  net_amount_cents    bigint NOT NULL,
  ledger_source text        NOT NULL DEFAULT 'postgres',  -- 'postgres' | 'tigerbeetle'
  PRIMARY KEY (id, created_at)               -- partition key lives in the PK
) PARTITION BY RANGE (created_at);

A few decisions are doing real work here. The composite primary key (id, created_at) exists because Postgres requires the partition key in the primary key — a small tax for range partitioning that keeps writes appending to the current quarter and lets old quarters be detached and archived whole. Money is bigint cents with an explicit currency elsewhere in the row; there is no float anywhere near a balance. And the split invariant — amount = facility_cut + platform_fee + net — is enforced in the fee-resolver service before the row is ever written, against a fee_rules table keyed on how the participant discovered the org (a direct booking and a FieldDay-discovered booking carry different take-rates, in basis points, with effective_from/until for the audit trail).

The one piece of foresight I allowed myself is that ledger_source enum. It costs nothing today and it's the seam a future dual-write runs through — which brings me to the place TigerBeetle does belong.

Where TigerBeetle actually earns its keep

Rejecting a tool for the general case isn't the same as never using it. The flows I worry about most are the sponsor-funded ones: a brand funds a pool, the pool subsidizes specific participations, and the remainder eventually flows to a community fund. That's the money with the most parties, the most steps, and the highest cost of getting a balance wrong.

So TigerBeetle is in the codebase — as a shadow ledger for sponsor flows only, writing in parallel with Postgres and reconciled against it, behind a flag. It is not load-bearing. The bar I set for graduating it to anything that touches a real payout is seven consecutive clean days of staging reconciliation with zero discrepancies. Until then Postgres is the source of truth and TigerBeetle is a hypothesis I'm testing in production-shaped conditions. That's the honest status: a pilot, not a launch.

The u128 bug that nearly misrouted money

Here's where the shadow ledger paid for itself before it shipped a single real transfer.

TigerBeetle account IDs are 128-bit integers, and I needed to derive them deterministically from FieldDay's entity UUIDs so the same sponsor always maps to the same account. My first version reserved the top byte as a namespace tag — 0x01 for a sponsor pool, 0x02 for an org-payable account — and folded it in with an XOR. Clean, until a property test started producing two different entities that mapped to the same account.

The cause: FieldDay uses UUIDv7, whose top byte is a timestamp and is essentially never zero. XOR-ing a tag into a byte that already has bits set doesn't reserve a namespace: the top byte no longer tells you which namespace an ID belongs to, so the guarantee the scheme existed for was gone. In a financial system that's the setup for a sponsor's funds and an org's payable landing on the same account ID. That is the worst class of bug there is: silent, rare, and about money.

128-bit account idbit 127 (MSB) → bit 0
Buggy — uuid ⊕ tag
⊕ tag
120-bit entity id (from UUIDv7)

UUIDv7's top byte is a timestamp — never zero — so XOR-ing a tag into it scrambles the namespace and collides with another ~1 in 256.

Fixed — (uuid & MASK) | tag
0x01
120-bit entity id (from UUIDv7)

Clear bits 120–127 (& BOTTOM_120_MASK), then OR a fixed tag. The namespace lives in bits the entity id can't touch — collision is structurally impossible.

8-bit tag · 120-bit entity — widths not to scale

Masking then setting beats XOR-ing a tag into bits that already carry data.

The fix is to stop being clever and clear the bits before setting them:

sponsor-ledger/account-ids.ts
// UUIDv7's top byte is never zero, so XOR-ing a tag collided ~1/256 and could route a
// sponsor's money into an org-payable account. Clear the top 8 bits, then OR a fixed tag.
const BOTTOM_120_MASK = (1n << 120n) - 1n; // bits 0..119 set
const POOL_TAG = 1n << 120n;               // 0x01 at bit 120 — sponsor pool
 
export function sponsorPoolAccountId(sponsorUuid: string): bigint {
  return (uuidToU128(sponsorUuid) & BOTTOM_120_MASK) | POOL_TAG;
}

Masking then setting makes collisions structurally impossible — two namespaces can never produce the same ID because the namespace lives in bits the entity ID can no longer touch. The lesson I keep relearning: with money, "probably unique" is a bug, and a property test that hammers the boundary will find it before a customer does.

Reconciliation is the real product

The thing nobody tells you about ledgers is that the database is the boring part. The product is the process that proves two independent records agree.

FieldDay runs a daily reconciliation job — leader-elected via a Postgres advisory lock so exactly one replica runs it — that is detection-only: it never mutates, it recomputes the transfer IDs it expects from the Postgres ledger and diffs them against TigerBeetle, then reports orphans by class — missing (in one, not the other) and amount_mismatch (in both, disagreeing). It can't fix anything; that's the point. A reconciler that auto-heals is a reconciler you've stopped trusting. Its only job is to make a discrepancy loud.

Every money event writes to both ledgers; a daily job proves they agree — and only speaks up when they don't.

Money event
Transfer
participation · payout · sponsor pool
Two ledgers
PostgreSQL
append-only · source of truth
TigerBeetle
shadow · flag-gated
Daily job
Reconciler
leader-elected · detection-only
Reports
missing
in one, not the other
amount_mismatch
both, disagreeing

Detection-only · The reconciler never mutates — a job that auto-heals is one you've stopped trusting. Its only output is a loud discrepancy.

Architecture · Postgres is the source of truth; TigerBeetle earns trust in the shadows

That job is what lets the shadow ledger earn trust, and it's also why the choice of database mattered less than I thought at the start. Postgres with append-only discipline and a real reconciler is a correct ledger. TigerBeetle without one is just a faster way to be wrong.

What I'd tell you to do

Don't buy a financial database before you have a financial-database problem. Model your ledger append-only in the database you already operate, enforce the balance invariant where the row is written, and put as much care into the reconciliation job as into the schema. Reserve the specialized tool for the specific, highest-stakes flow where its guarantees pay for the operational cost — and make it prove itself in the shadows, against the boring database, before it's allowed near a payout.

The exciting choice and the right choice are usually different sizes. Write down the number that would change your mind, and until you hit it, ship the boring one.

— Owen

Thanks for reading. Cheers.

If you spot something wrong in this post, or you’d have done it differently, tell me. I’d rather be corrected than confidently wrong. owenlrichards2000@gmail.com

— Owen