All entries
Chapter VIII
Journal · 20 Sept 2026 · 11 min read

TigerBeetle vs Postgres for Payment Ledgers

Payment ledgers on Postgres die on one contended account row, not on total throughput. Real DDL, the SQLSTATEs that matter, and when to move.

A payment ledger on Postgres rarely fails because Postgres is slow. It fails because every payout in a marketplace touches the same platform account row, and one row serialises writes however many cores you add. SoftwareMill's published benchmark puts TigerBeetle around 42,000 transfers per second sustained and 60,000 in burst, against roughly 15,000 for Postgres with batched transfers and about 6,400 when the Postgres path uses explicit row locking.

Search for how to build one of these and page one hands you bookkeeping explainers written for people who own a shop. Xero, FreshBooks, Bill.com, a Paylocity glossary stub. The two genuinely useful engineering pages, Alex Xu's payment system chapter and Oskar Dudycz on ledger databases, publish their schemas as images or not at all. So here is the part everyone skips: the DDL, the error codes on both sides, and the point where a relational ledger stops being the right answer.

What Is a Double-Entry Ledger in a Payment System?

Three properties, and everything else is implementation detail.

Every movement of money writes at least two entries that sum to zero within a currency. The journal is append-only, so a mistake is corrected by a compensating entry rather than an UPDATE. And a balance is derived from entries rather than stored as the truth.

Most ledger bugs I have seen come from breaking the third one quietly. Someone adds a cached balance for a dashboard, the cache and the entries diverge under concurrency, and the cache is what the payout job reads.

Why a Balance Column on the Accounts Table Breaks

The tempting design is UPDATE account SET balance = balance - 500 WHERE id = ?. It is atomic, it is fast, and it is wrong for three separate reasons.

It destroys history. After the update you know the balance and nothing about how it got there, so a dispute six months later has no answer.

It has no counterparty. Money left an account and arrived nowhere. Nothing in the schema stops the two halves diverging.

It concentrates contention on exactly the rows you care about most. Your platform fee account, your float account and your settlement account appear in most transactions, and each one is a single row that every writer must queue behind.

Payment Ledger Schema in Postgres: The DDL That Holds

Two tables carry the weight. A transaction groups entries; entries carry the money.

CREATE TABLE ledger_transaction (
  id              uuid PRIMARY KEY,
  idempotency_key text        NOT NULL UNIQUE,
  kind            text        NOT NULL,
  occurred_at     timestamptz NOT NULL,
  recorded_at     timestamptz NOT NULL DEFAULT now(),
  reverses        uuid        REFERENCES ledger_transaction(id)
);

CREATE TABLE ledger_entry ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, transaction_id uuid NOT NULL REFERENCES ledger_transaction(id), account_id uuid NOT NULL REFERENCES ledger_account(id), currency char(3) NOT NULL, amount_minor bigint NOT NULL CHECK (amount_minor > 0), direction smallint NOT NULL CHECK (direction IN (-1, 1)) );

CREATE INDEX ledger_entry_account_idx ON ledger_entry (account_id, currency, id);

Four decisions in there are load-bearing.

amount_minor is a bigint in minor units, never float, and direction is separate from the amount so a sign error cannot turn a debit into a credit silently. Currencies with zero or three minor units make this less obvious than it looks, which is its own ISO 4217 problem. occurred_at and recorded_at are distinct columns. Late-arriving settlement files post today for value three days ago, and if you only keep one timestamp you cannot answer "what did this account hold at the close of Tuesday" once a backdated entry lands.

A reversal is a new transaction pointing at the original through reverses. There is no DELETE path.

The balanced-transaction rule is the constraint nobody writes down, because a normal CHECK cannot express it. It spans rows. The mechanism you want is a deferred constraint trigger:

CREATE OR REPLACE FUNCTION assert_transaction_balanced() RETURNS trigger AS $$
BEGIN
  IF EXISTS (
    SELECT 1 FROM ledger_entry
     WHERE transaction_id = NEW.transaction_id
     GROUP BY currency
    HAVING SUM(amount_minor * direction) <> 0
  ) THEN
    RAISE EXCEPTION 'transaction % is unbalanced', NEW.transaction_id
      USING ERRCODE = '23514';
  END IF;
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER ledger_entry_balanced AFTER INSERT ON ledger_entry DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION assert_transaction_balanced();

DEFERRABLE INITIALLY DEFERRED is the whole trick. The check runs at COMMIT, not after each row, so you can insert the debit and the credit in either order. Grouping by currency stops a EUR debit balancing a GBP credit, which is the multi-currency bug that survives every code review because the arithmetic looks right.

Then take the write privileges away, because a constraint that the application can route around is documentation:

REVOKE UPDATE, DELETE ON ledger_entry, ledger_transaction FROM app_user;

How Do You Compute a Balance Without Summing Every Row?

SUM() over every entry is correct and gets slower forever. A materialised balance row is fast and contended. The usable middle is a checkpoint.
CREATE TABLE ledger_balance_checkpoint (
  account_id     uuid    NOT NULL,
  currency       char(3) NOT NULL,
  up_to_entry_id bigint  NOT NULL,
  balance_minor  bigint  NOT NULL,
  PRIMARY KEY (account_id, currency)
);

A balance is the checkpoint plus the entries after it. The checkpoint advances on a schedule rather than in the payment path, so writers never block on it, and the tail stays short enough to sum cheaply. Because the journal is immutable, a checkpoint can always be rebuilt from zero, which means a corrupt one is an inconvenience rather than an incident.

For the hot accounts that appear in most transactions, shard the account itself. Ten sibling rows for the platform fee account, a writer picks one, and the logical balance is their sum. You have traded one contended row for ten, and you can go to a hundred.

Postgres Ledger Errors: 40001, 40P01 and 23505

The error codes are the API your service layer actually programmes against, and each one means something different to a caller.

SQLSTATEConditionWhat it means hereCorrect response
40001serialization_failureTwo transactions read and wrote overlapping state under SERIALIZABLERetry the whole transaction, with backoff
40P01deadlock_detectedTwo writers took row locks in opposite orderRetry, and fix the lock ordering
23505unique_violationDuplicate idempotency_keyNot an error. Return the stored result
23514check_violationUnbalanced transaction, or a negative amountA bug. Fail loudly, never retry

Two of those are retryable and two are not, and conflating them is how a duplicate payout happens. A 23505 on the idempotency key is the system working as designed; retrying it as though it were transient is how you get two attempts racing. A 23514 retried in a loop is a bug that will retry forever.

If you run SERIALIZABLE, 40001 is not an exceptional path, it is the normal cost of the isolation level, and every ledger write needs a retry wrapper. Teams that instead use READ COMMITTED with SELECT ... FOR UPDATE trade those retries for explicit lock waits, which is the configuration that produced SoftwareMill's slower Postgres number. Idempotency semantics differ between payment providers too, and the idempotency key guide covers where they disagree.

TigerBeetle vs Postgres for Payment Ledgers: The Trade-off

TigerBeetle discards the things that make Postgres flexible. A transfer is a fixed 128-byte record with u128 identifiers and amounts, a ledger, a code, flags and a timeout. There is no SQL, no schema of your own, no free-text metadata, and a hard limit of 8,189 events per request. Two-phase holds are first-class through the pending, post_pending_transfer and void_pending_transfer flags, so an authorisation that expires needs no reaper job.

PostgresTigerBeetle
SchemaYoursFixed accounts and transfers
MetadataAny column you likeThree user_data fields, 128/64/32 bits
QueriesFull SQL, joins, reportingLookups by id and account filters
HoldsYou build themBuilt in, with timeouts
IdempotencyUNIQUE on your keyThe transfer id itself
Correctness evidenceDecades of production useJepsen analysis, June 2025
Operational costOne thing you already runA second datastore to operate

The honest trade-off is not throughput. It is blast radius against query flexibility. Postgres lets you join the ledger to customers, disputes and KYC state in one query, which is most of what a payments team does all day. TigerBeetle refuses, so you keep all of that in Postgres anyway and now run two systems that must agree.

That is the cost nobody quotes: choosing TigerBeetle does not replace your database, it adds one, along with the reconciliation between them.

Why Do TigerBeetle Benchmarks Disagree So Wildly?

This is worth settling, because the published numbers contradict each other badly. One independent write-up measured 46,614 transfers per second on a single TigerBeetle worker against 3,356 requests per second from a ten-worker Postgres setup. A community benchmark repository reports the opposite, calling TigerBeetle one to two orders of magnitude slower than Postgres or Redis except at a batch size of 1,000.

Both are probably running their code correctly. The variable is batching.

TigerBeetle's design assumes you amortise consensus and disk over a large batch, which is why the 8,189-event limit exists and why its numbers collapse at batch size one. Benchmark it one transfer per request and you measure round-trip latency through a consensus protocol, which is a genuinely bad way to spend a network hop. The same write-up measured 4,177,054 fsyncs across 10 million Postgres inserts, and write amplification of 8.2× against 1.2×, which is the mechanism underneath the headline.

My position: treat every TigerBeetle throughput figure as a claim about batch size until proven otherwise, and if your payment flow is one synchronous transfer per HTTP request with no natural batching, the advertised numbers do not describe your workload.

What UK Safeguarding Rules Demand of Your Ledger Schema

Not one page ranking for these queries mentions a regulator, which is odd, because in the UK the rules reach directly into the schema.

The safeguarding requirement under CASS 15 sums individual client balances while ignoring negative ones. A client in debit does not net against one in credit. That is a one-line difference in your query and a material difference in the number:

-- Correct: a client in debit does not reduce the requirement
SELECT SUM(GREATEST(balance_minor, 0)) FROM client_balance;

Two more constraints fall out of the same rulebook. CASS 15.8.21R requires a fixed daily reconciliation point, which is the effective-dating requirement restated: you must reproduce every client balance as at a specific instant, which an append-only journal with occurred_at gives you and a mutable balance column does not. And CASS 15.2.5R makes unallocated receipts count towards the requirement before you know whose money it is, so "suspense" needs to be a real account in the ledger with a real balance, not a nullable column. The CASS 15 reconciliation guide works through the timing rules in detail.

My Take: Stay on Postgres Until One Account Melts

Most teams reaching for a specialist ledger database are solving a contention problem they could solve with sharded hot accounts and a checkpoint table, and they are solving it by adding a datastore that cannot answer their reporting queries.

Start on Postgres. Get the constraints right, because the schema is the part that is genuinely hard to change later once real money is in it. Measure the contended accounts specifically, not aggregate throughput, since aggregate numbers hide the single row that will actually stop you.

Move when the hot account still queues after sharding, when holds and expiries are a meaningful share of your code, or when you need correctness evidence stronger than your own test suite. That is a real threshold and some firms genuinely cross it. It is just much further away than the benchmark blog posts imply, and the teams I have watched migrate early spent their first quarter building the reconciliation between two databases rather than shipping features.

What This Means for Payments Engineers

1. Make the balanced rule a deferred constraint trigger, grouped by currency, not application code. Application-level checks are bypassed by the next migration script somebody runs. 2. Revoke UPDATE and DELETE on both ledger tables from the application role, and make reversals the only correction path. 3. Separate occurred_at from recorded_at now. Retrofitting effective dating onto a live ledger means rewriting history you are not allowed to rewrite. 4. Map SQLSTATEs deliberately. Retry 40001 and 40P01, replay the stored result on 23505, and page someone on 23514. 5. Shard hot accounts before changing database. Ten rows for the platform account is an afternoon; a second datastore is a quarter. 6. Floor negative balances in any safeguarding calculation, and test it with a client in debit. 7. Benchmark your own batch size. If you cannot batch, TigerBeetle's published figures do not apply to you.

If you are choosing between building this and buying it, the open source money transfer stack guide compares the ledger products and their licences. For a second opinion on a ledger schema before it carries real money, you can find me via Tom Wang.

Key Takeaways

  • Payment ledgers on Postgres fail on contended account rows, not aggregate throughput. Measure the hot account, not the average.
  • The balanced-transaction invariant needs a DEFERRABLE INITIALLY DEFERRED constraint trigger grouped by currency. A row-level CHECK cannot express it.
  • 40001 and 40P01 are retryable, 23505 means return the stored result, and 23514 is a bug. Treating them alike causes duplicate payouts.
  • TigerBeetle's advertised throughput assumes large batches, with 8,189 events per request as the ceiling. Contradictory public benchmarks mostly differ on batch size.
  • Choosing TigerBeetle adds a datastore rather than replacing one, because your reporting queries still need SQL.
  • UK safeguarding sums client balances while ignoring negative ones, so SUM(balance) under-funds and SUM(GREATEST(balance, 0)) does not.