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
transactions / second · log scale
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.
-- 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.
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.
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
The fix is to stop being clever and clear the bits before setting them:
// 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.
Detection-only · The reconciler never mutates — a job that auto-heals is one you've stopped trusting. Its only output is a loud discrepancy.
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