TigerBeetle vs PostgreSQL for Financial Ledgers: Debit-Credit Database Design

TigerBeetle vs PostgreSQL for Financial Ledgers: Debit-Credit Database Design

TigerBeetle Ledger vs PostgreSQL: Debit-Credit Database Design

Most payment platforms start their ledger as three tables in PostgreSQL and a transaction that updates two balances. It works for years, and then one account, usually the platform’s own fee or settlement account, appears in every payment. Row locks on that account serialize the whole system, and adding application servers makes the queue longer, not shorter.

A TigerBeetle ledger attacks that failure mode from the other side. It is a purpose-built database whose only data model is the account and the transfer, whose business rules run inside the database, and whose storage engine and consensus protocol are built around the assumption that disks lie. This article compares it against a well-built PostgreSQL ledger on data model, concurrency, two-phase payments, replication, testing and operations, using TigerBeetle’s own documentation and the 2025 Jepsen report as sources.

What this covers: the double-entry record formats in both systems, real SQL and client code, why hot accounts break row-locking designs, how pending/post/void transfers model card authorizations, what Viewstamped Replication and deterministic simulation buy you, and a decision framework for choosing between them.

This is systems analysis, not financial, legal or investment advice.

Context and Background

A double-entry ledger records every movement of value as a debit on one account and an equal credit on another, so the sum of all balances is always zero. That invariant is the oldest consistency check in engineering. Our primer on double-entry ledger database architecture covers the accounting model and the general storage options. Here we focus on one choice within it: a general-purpose relational database versus a specialised debit-credit database.

PostgreSQL is the incumbent for good reasons. It has mature tooling, point-in-time recovery, rich queries, and a team that already knows how to run it. Most open-source ledger services, and many in-house ones, sit on top of it with a schema of accounts, transactions and entries. For moderate volumes and low contention, that is the right answer, and nothing below should talk you out of it.

TigerBeetle is the challenger. Its documentation describes it as an OLTP database that works alongside a general-purpose database rather than replacing it, intended for the “data plane, or hot path of transaction processing.” It is written in Zig, has no external dependencies, and exposes a deliberately tiny interface: create accounts, create transfers, and look things up. The company behind it commissioned an independent Jepsen analysis, published on 2025-06-06 by Kyle Kingsbury, which we will use as a reality check on the marketing.

The framing matters. This is not “new database beats old database.” It is a question of where you want the double-entry invariants enforced: in application code and SQL constraints that you write and test, or inside a storage engine designed around that one workload. Both choices are defensible, and they fail differently.

Payments context raises the stakes. As flows move toward agents and tokenised credentials, as covered in agentic payments architecture and network tokenization architecture, the number of small, automated, high-contention transfers grows. Richer message formats, as in ISO 20022 migration, add metadata but do not remove the need for an exact balance underneath.

Reference Architecture: Control Plane and Data Plane

The architecture TigerBeetle recommends is a split: TigerBeetle holds balances and transfers, while a general-purpose database such as PostgreSQL holds everything descriptive. A stateless API service authenticates the caller, batches transfers, and writes to both. The key rule from the documentation is that initiating a transfer should not require fetching metadata from the general-purpose database, because that database would then become the bottleneck.

TigerBeetle ledger architecture with PostgreSQL as control plane

Figure 1: Data plane in TigerBeetle, control plane in PostgreSQL, joined by integer IDs and user_data pointers.

The diagram shows one stateless API tier writing to two stores. Customer names, ledger descriptions and the mapping from integer type codes to strings live in PostgreSQL. Balances, pending reservations and the immutable transfer history live in TigerBeetle, which can carry pointers back to PostgreSQL rows in its user_data fields.

The direct answer to “what does each system store?” is therefore: PostgreSQL stores what things are, TigerBeetle stores how much and when. A purely PostgreSQL design stores both in one engine and pays for it in lock contention. A purely TigerBeetle design is impossible, because it deliberately has no place for names, addresses or documents.

The TigerBeetle data model

An account is a fixed 128-byte record. Per the reference documentation it contains a 128-bit id; four 128-bit counters debits_pending, debits_posted, credits_pending and credits_posted; three application fields user_data_128, user_data_64 and user_data_32; a 32-bit ledger; a 16-bit code; 16-bit flags; and a cluster-assigned 64-bit timestamp, plus 4 reserved bytes. The field sizes sum to exactly 128 bytes.

Notice what is absent: there is no balance column. TigerBeetle tracks cumulative posted and pending debits and credits, and the application derives the balance. For asset and expense accounts the balance is debits minus credits; for liability, equity and income accounts it is credits minus debits. This is the standard accounting convention, expressed directly in the data model.

A transfer is also 128 bytes: id, debit_account_id, credit_account_id, amount (all 128-bit), pending_id, three user_data fields, a 32-bit timeout, ledger, code, flags and a timestamp. Transfers are immutable. You cannot edit or delete one, and mistakes are fixed with a correcting transfer, exactly as in a paper ledger.

Amounts are unsigned 128-bit integers. The documentation recommends mapping the smallest useful unit to 1 and treating the power-of-ten exponent as the asset scale: USD in cents has scale 2, so 0.45 USD is 45; JPY has scale 0; KWD has scale 3, so 0.450 KWD is 450. Scales are hard to change later, because accounts and transfers are immutable; changing one means a new ledger and copying accounts across.

Account flags as database-enforced invariants

The flags field is where business rules become storage rules. debits_must_not_exceed_credits makes a liability account (a customer wallet) reject any transfer that would overdraw it. credits_must_not_exceed_debits does the mirror image for asset accounts. The two are mutually exclusive. history retains per-transfer balance snapshots so that get_account_balances works, imported allows migrating historical data with user-supplied timestamps, and closed blocks further transfers except voids of pending ones.

The consequence is that the overdraft check and the balance update cannot be separated by a race. In PostgreSQL you reproduce this with a CHECK constraint, a locked read, or serializable isolation. In TigerBeetle, it is a property of the account record that every transfer is evaluated against.

The ledger and code fields

The ledger field partitions accounts, typically by currency or asset type. Only accounts on the same ledger can transact directly, so a currency exchange is two linked transfers across two ledgers via liquidity accounts you own. The code field records the “why”: on accounts it can encode the chart-of-accounts category, and on transfers the purpose, such as purchase, refund or fee. Both are plain integers, with their meanings held in your control plane.

Multi-tenant platforms can use one ledger per customer. The user_data fields are indexed for point and range queries, so user_data_128 might hold an order or customer reference, user_data_64 a real-world event time for bitemporal reporting, and user_data_32 a jurisdiction.

Linked events: atomic multi-leg movements

Real payments rarely have two legs. A card purchase might debit the customer, credit the merchant, and credit a fee account. TigerBeetle models this with the linked flag: a transfer carrying it ties its outcome to the next transfer in the same request, so the whole chain succeeds or fails together. The last transfer in the chain does not set the flag. Accounts can be created in linked chains the same way.

Here is a minimal sketch using the Python client. It follows the documented field names; treat client-library details as version-dependent and check the current client reference before copying.

import tigerbeetle as tb

client = tb.ClientSync(cluster_id=0, replica_addresses="3000")

USD_LEDGER = 1
CODE_PURCHASE, CODE_FEE = 10, 11

customer, merchant, fees = 1001, 2001, 3001

client.create_accounts([
    tb.Account(id=customer, ledger=USD_LEDGER, code=100,
               flags=tb.AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS),
    tb.Account(id=merchant, ledger=USD_LEDGER, code=200),
    tb.Account(id=fees,     ledger=USD_LEDGER, code=300),
])

# Fund the customer from a house account, then a three-leg linked purchase.
transfers = [
    tb.Transfer(id=tb.id(), debit_account_id=customer, credit_account_id=merchant,
                amount=10_000, ledger=USD_LEDGER, code=CODE_PURCHASE,
                flags=tb.TransferFlags.LINKED),
    tb.Transfer(id=tb.id(), debit_account_id=customer, credit_account_id=fees,
                amount=290, ledger=USD_LEDGER, code=CODE_FEE),
]
errors = client.create_transfers(transfers)

If the customer cannot cover both legs, neither transfer is applied. There is no application-level compensation to write for that case.

Deeper Analysis: Where Row-Locking Ledgers Break

The classic PostgreSQL ledger is three tables: accounts, transactions, and entries, where each entry is a signed amount against an account. A strong version keeps entries append-only, enforces sum(amount) = 0 per transaction with a deferred constraint trigger, and caches balances on the account row for fast reads.

CREATE TABLE account (
  id          bigint PRIMARY KEY,
  ledger      int    NOT NULL,
  balance     numeric(38,0) NOT NULL DEFAULT 0,
  allow_negative boolean NOT NULL DEFAULT false,
  CHECK (allow_negative OR balance >= 0)
);

CREATE TABLE txn (
  id          uuid PRIMARY KEY,           -- idempotency key
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE entry (
  id          bigserial PRIMARY KEY,
  txn_id      uuid   NOT NULL REFERENCES txn(id),
  account_id  bigint NOT NULL REFERENCES account(id),
  amount      numeric(38,0) NOT NULL      -- signed: credit positive
);

-- Transfer 10000 from customer to merchant, atomically
BEGIN;
INSERT INTO txn(id) VALUES ('7b1c...')      -- fails on retry: idempotency
  ON CONFLICT DO NOTHING;
-- lock in a consistent order to avoid deadlocks
SELECT id FROM account WHERE id IN (1001, 2001) ORDER BY id FOR UPDATE;
INSERT INTO entry(txn_id, account_id, amount)
  VALUES ('7b1c...', 1001, -10000), ('7b1c...', 2001, 10000);
UPDATE account SET balance = balance - 10000 WHERE id = 1001;
UPDATE account SET balance = balance + 10000 WHERE id = 2001;
COMMIT;

This is correct. The CHECK prevents overdrafts, the primary key on txn.id provides idempotency, and ordered locking avoids deadlocks. The weakness is not correctness; it is what the lock does to throughput.

The hot-account problem

SELECT ... FOR UPDATE takes a row-level lock held until commit. Every transaction touching the merchant, fee or settlement account must wait its turn. The lock is held for the duration of the transaction, including every network round trip between your application and the database, plus the commit’s durable write.

Sequence of transactions queueing on a hot account row lock

Figure 3: Three transactions serialising on one hot row, each waiting for the previous commit.

The arithmetic is unforgiving, and the figures here are illustrative, not measured. If the lock is held for about 2 ms per transaction (a few round trips plus a synchronous commit), the ceiling for any single account is roughly 500 transactions per second, regardless of how many application servers or CPU cores you add. Shaving the hold time to 0.5 ms raises the ceiling to about 2,000 per second, but the shape is the same: throughput on a hot row is 1 divided by lock hold time.

The TigerBeetle performance documentation makes the same argument. A small number of hot accounts appear in many transactions, so sharding by account just moves the bottleneck onto one shard. Its answer is to move the logic into the database so that no lock is held over the network, then batch aggressively and run on a single core with a single leader.

Workarounds, and what they cost

Postgres practitioners have standard mitigations, and a fair comparison has to include them. None is free.

Sharded sub-accounts. Split the fee account into N sub-accounts and pick one at random per transfer. Contention falls by N, but balance reads now sum N rows, reconciliation gets more complex, and any rule on the aggregate (a cap, say) can no longer be checked atomically.

Deferred or batched balance updates. Insert only entry rows on the hot path and compute balances asynchronously. Inserts do not contend, but you lose synchronous overdraft protection on that account, so it only suits accounts where negative momentary balance is acceptable, such as platform revenue.

Optimistic concurrency with SERIALIZABLE. Postgres implements serializable snapshot isolation, which avoids blocking but aborts conflicting transactions with SQLSTATE 40001. Your application must retry, and under heavy contention retry storms can reduce throughput below the locking approach. This is the safest isolation level and also the one most likely to surprise you under load.

Batching in the application. Group many payments into one transaction that updates the hot account once. This is effectively what TigerBeetle does inside the database, but now you own the batching, the partial-failure semantics, and the idempotency mapping from batch back to individual requests.

TigerBeetle’s design response

TigerBeetle’s performance page lists four ideas. First, business logic runs inside the database, so locks never span a network trip. Second, each request carries a batch of up to 8,190 transfers, so the replication cost is paid once per batch, not per transfer; under light load batches shrink to favour latency. Third, execution is single-threaded on one core with a single leader, because hot accounts would defeat sharding. Fourth, the implementation is in Zig with static memory allocation, no garbage collector, and 128-byte objects aligned to cache lines.

Two caveats are worth stating plainly. Adding nodes improves reliability, not throughput; you cannot scale a TigerBeetle cluster out for more transfers per second. And the system supports only the strictest isolation level, so transfers execute strictly one at a time. The documentation presents this as a safety feature against operator misconfiguration, and it is also a design ceiling.

This article does not quote throughput numbers. Vendor figures depend on hardware, batch size, and workload shape, and I did not verify a reproducible third-party benchmark for this run. Measure with your own account distribution before drawing conclusions.

Two-Phase Transfers: Modelling Authorizations

Card payments, bank holds and escrow all need to reserve value now and settle it later. A relational design usually adds a status column and a separate holds table, with logic to subtract holds from available balance. TigerBeetle makes the pattern a primitive: the two-phase transfer.

Lifecycle of a TigerBeetle two-phase transfer from pending to final

Figure 2: A pending transfer resolves exactly once as post, void or timeout expiry.

The mechanics

A transfer with flags.pending increases debits_pending on the debit account and credits_pending on the credit account, leaving the posted fields untouched. Reserved amounts are checked when the pending transfer is created, so one that would break a debits_must_not_exceed_credits limit fails immediately instead of at post time. The documentation’s example: an account with 100 credits posted and 70 debits posted cannot accept a pending transfer that would push debits_pending to 50.

To resolve it, a second transfer references the first through pending_id. With post_pending_transfer, the reserved amount moves into the posted fields. A smaller amount posts only that part and releases the remainder, which fits tip adjustments and partial captures. An amount of AMOUNT_MAX (2^128 – 1) posts the full pending amount. An amount above the pending amount returns exceeds_pending_transfer_amount. With void_pending_transfer, the whole reservation is released.

Note a version caveat from the docs: before client 0.16.0, posting an amount of 0 meant “post in full”. Code ported from older clients should be checked for this.

Timeouts and immutability

A pending transfer may carry a timeout in seconds, measured from its cluster-assigned timestamp, not an absolute time, which makes it tolerant of clock skew between the application and the cluster. Expiry occurs exactly at timestamp plus timeout, and an expired transfer can no longer be posted or voided. Removal of the pending balance is best-effort, so clients may briefly still see a reservation after expiry.

A pending transfer can be resolved at most once; repeat attempts return pending_transfer_already_posted, pending_transfer_already_voided or pending_transfer_expired. Resolving does not modify the original; it creates a new immutable transfer. The history therefore reads as an append-only audit trail with no status column to overwrite.

AUTH_TIMEOUT = 7 * 24 * 3600  # seconds, a card-style authorization window

pending_id = tb.id()
client.create_transfers([tb.Transfer(
    id=pending_id, debit_account_id=customer, credit_account_id=merchant,
    amount=10_000, ledger=USD_LEDGER, code=CODE_PURCHASE,
    timeout=AUTH_TIMEOUT, flags=tb.TransferFlags.PENDING)])

# Capture a smaller final amount (for example, after an item is removed)
client.create_transfers([tb.Transfer(
    id=tb.id(), pending_id=pending_id, amount=9_500,
    flags=tb.TransferFlags.POST_PENDING_TRANSFER)])

The same pattern in PostgreSQL

In SQL you can model holds with a hold table and compute available = balance - sum(active holds). To keep the overdraft check atomic, the hold insert must lock the account row, so reservations contend exactly like transfers do. Expiry needs a sweeper job or a lazy check on read, and every reader must apply the same expiry rule.

-- Reserve funds: lock the account row, check available, insert hold
BEGIN;
SELECT balance FROM account WHERE id = 1001 FOR UPDATE;
SELECT balance - COALESCE((SELECT sum(amount) FROM hold
        WHERE account_id = 1001 AND state = 'active'
          AND expires_at > now()), 0) AS available
  FROM account WHERE id = 1001;
-- application checks available >= 10000, then:
INSERT INTO hold(id, account_id, amount, state, expires_at)
  VALUES ('a91e...', 1001, 10000, 'active', now() + interval '7 days');
COMMIT;

This is perfectly workable, and it gives you flexibility TigerBeetle intentionally omits, such as arbitrary hold metadata. The price is that expiry semantics, resolve-once semantics and the available-balance formula are all your code. Jepsen’s report notes that pending-transfer timeouts are hard to test robustly because they may not void a transfer until after the deadline, which is a reminder that expiry is subtle in any system.

Consensus, Storage and Testing

Throughput matters less than the question every ledger owner eventually asks: what happens when a machine dies, or a disk returns the wrong bytes? This is where the two systems differ most in philosophy.

Replication path and simulation testing in a TigerBeetle cluster

Figure 4: A batch is prepared on a quorum, executed on a single core, and exercised continuously by the VOPR simulator.

Viewstamped Replication

TigerBeetle replicates with Viewstamped Replication (VSR), a consensus protocol that predates Raft and Paxos in common use. A primary assigns an order to each batch, sends it to backups, and commits once a quorum acknowledges. If the primary fails, a view change elects a new one. The documentation states that consensus matters mainly at failover; during normal operation the cost is replication of each batch.

For maximum availability the documentation recommends six replicas across three cloud providers, two per provider. With flexible quorums, this tolerates the complete loss of a provider and likely one more replica. That is an unusual operating model, and one I would only adopt after rehearsing the failure drills.

PostgreSQL’s equivalent is streaming replication with a failover manager such as Patroni, and synchronous replication where you cannot lose commits. It is well understood, but consensus lives in an external system (etcd, Consul or similar) rather than in the data store itself, and the correctness of failover depends on the configuration of both.

The storage fault model

TigerBeetle’s safety page states it assumes its disk will fail. Data is checksummed and hash-chained, so latent sector errors are detected and repaired from other replicas. It uses O_DIRECT and its own page cache to avoid trusting the kernel’s. With Protocol-Aware Recovery, the cluster stays available after write-ahead-log corruption, and data is lost only if corrupted on every replica, in which case the design halts rather than proceeding with bad data.

PostgreSQL offers page checksums (enabled at cluster initialisation or later with pg_checksums) and WAL checksums, which detect corruption, but repair typically means restoring from a backup or replica by operator action. Neither approach is wrong; they target different levels of automation.

Deterministic simulation testing

The distinctive engineering practice is the VOPR, TigerBeetle’s deterministic simulator. According to the safety documentation, it runs an entire cluster of real code in a simulated environment, subjects it to network, storage and process faults at 1000x speed, and runs continuously on 1,024 cores. Because the simulation is deterministic, any failure can be replayed exactly from its seed.

The Jepsen report gives an honest assessment. VOPR and fuzzers caught many bugs before and during the engagement. But the VOPR missed a padding bug because it corrupted whole sectors; it was changed to introduce single-byte errors, which reproduced the bug. A query bug escaped all four index-scan fuzzers because their generated objects were consecutive in each index; rewriting the fuzzer to generate less predictable objects found it quickly. Simulation is powerful, and it is limited by the fault model its authors imagined.

What Jepsen found

The report tested versions 0.16.11 through 0.16.30, including development builds, on three- to six-node clusters. It found two safety bugs: multi-filter queries sometimes omitting results (fixed in 0.16.17) and a Java client debugging API returning incorrect timestamps (fixed in 0.16.14). It found seven crash bugs on client and server, and several availability issues, including a failed node raising latencies by orders of magnitude (improved in 0.16.30 and 0.16.43) and no recovery path for a node that lost its data (a tigerbeetle recover command arrived in 0.16.43).

Jepsen concluded that from 0.16.26 onward its findings matched TigerBeetle’s claim of strong serializability under pauses, crashes, partitions, clock errors, disk corruption and upgrades, and called resilience to data-file corruption exceptional. It recommended upgrading to 0.16.43, and noted that 0.16.45 addressed everything except indefinite retries: requests never time out, an issue left open. Jepsen also cautions it can prove bugs present but not absent.

The takeaway for a buyer is balanced. A young database with an independent audit and fast fixes is arguably in better shape than most. It is also young: two safety bugs in the audited range mean you should pin versions, read release notes, and test upgrades.

Operating the Two Systems

Reliable submission and idempotency

Money systems retry, so every write needs a stable identity. In TigerBeetle, the transfer id is the idempotency key: at most one transfer exists for a given id across the whole cluster, not per ledger. The application generates ids, and a retry with the same id returns an exists result instead of double-applying. One subtlety from the docs: retries of balancing transfers return exists_with_different_amount only if the original maximum amount was too small for the amount actually transferred, so retry logic should be tested on those paths.

In PostgreSQL the same guarantee comes from a primary key or unique constraint on a client-supplied key, as in the SQL above. The difference is who must remember it. With TigerBeetle the property is intrinsic to the record; with Postgres it is a convention that every code path must follow, including the batch jobs and manual fixes.

Batching and the API tier

Because throughput depends on batch size, the stateless API service should accumulate transfers from many concurrent requests into one TigerBeetle request, as the system-architecture documentation describes. The service must then map each result code back to the originating HTTP request. A simple design uses a short collection window or a size threshold, whichever comes first, and keeps a table of in-flight request ids.

Two behaviours deserve testing. If one transfer in a batch fails, the others still succeed unless you linked them, so linking is a deliberate choice. And because a client’s requests never time out in the audited versions, wrap calls with your own deadline and retry with the same ids.

Security boundary

TigerBeetle does not support authentication. Untrusted users and services must never talk to it directly, and untrusted processes must not touch its data file. Put it on a private network behind your API tier, and treat the API tier as the authorisation layer. PostgreSQL has roles, row-level security and TLS client certificates built in, which makes direct access control easier to reason about.

Reconciliation and reporting

Reconciliation is where purpose-built ledgers surprise teams. TigerBeetle gives you balances and transfers, indexed by user_data and queryable with filters, but it is not a reporting database. Month-end reports, joins against customer attributes and ad hoc audits belong in a warehouse. The usual pattern is to export transfers from TigerBeetle (through periodic filtered queries, or a change-feed mechanism if your version offers one) into PostgreSQL or an analytics store, and reconcile there against bank statements and processor files.

Plan the reconciliation invariants up front. Examples: the sum of all debits posted equals the sum of all credits posted per ledger; every transfer with a given user_data_128 order reference matches an order in the control plane; pending balances older than the longest allowed timeout are zero. Run these continuously, not at month-end.

Migration

Moving an existing PostgreSQL ledger into TigerBeetle is feasible because the imported flag lets you supply historical timestamps. Imported events must have unique, strictly increasing timestamps that are not in the future, imported transfers cannot have timeouts, and a batch cannot mix imported and normal events. Dual-writing for a period, with continuous reconciliation between the two, is the safest path.

Trade-offs, Gotchas, and What Goes Wrong

You cannot scale out for throughput. The single-leader, single-core design means a TigerBeetle cluster’s throughput is bounded by one core, however many replicas you add. For almost every business that bound is far above need, but a global-scale processor must check it against its volume.

The data model is deliberately small. No schema for customers, no arbitrary columns, no joins. If your product constantly adds per-transaction attributes, you will store them in Postgres and link by ID, which brings back a dual-write problem: a transfer can succeed in TigerBeetle while the metadata insert fails. Design for it with an outbox pattern or write metadata first and treat orphaned rows as garbage.

Dual-write consistency is your problem. Two databases mean two failure domains. A robust design uses the transfer id as the correlation key, writes the intent to PostgreSQL first, executes the transfer, then marks the intent complete, with a reconciler that resolves any intent that is stuck.

Asset scales are close to permanent. As noted, changing scale means a new ledger and copying accounts. Choose scale per currency with care, and decide your rounding policy for fees and FX before the first transfer.

Versions move quickly. Jepsen tested a range of 0.16.x releases and several fixes landed within that range. Pin versions, test upgrades in staging, and read the changelog. Client semantics have changed too, as the post-with-zero-amount change in 0.16.0 shows.

Postgres is not safe by default either. Common PostgreSQL ledger failures include READ COMMITTED lost updates when balances are read then written without locking, deadlocks from inconsistent lock order, retry storms at SERIALIZABLE, and long transactions that bloat tables and delay vacuum. A hot account also hurts everything else on the instance by holding connections.

Operational novelty. Fewer people know how to run TigerBeetle than PostgreSQL. Backup, restore, disaster recovery and on-call runbooks need to be built, and your cloud provider will not offer a managed Postgres-style service with everything included by default. Weigh that against the safety properties.

Practical Recommendations

Choose by contention profile and by who must be trusted to enforce the invariants, not by raw speed.

If your peak load is a few hundred transfers per second spread over many accounts, use PostgreSQL. Use an append-only entry table, a deferred zero-sum constraint, ordered row locking and a unique idempotency key. You get SQL, tooling and one failure domain, and you can revisit the decision with data.

If a handful of accounts (fees, settlement, treasury, a marketplace operator) appear in most transactions, or if you need tens of thousands of transfers per second with strict serializability, evaluate TigerBeetle for the data plane. Keep PostgreSQL for the control plane, and plan for dual-write reconciliation from day one.

If you are somewhere in between, start with PostgreSQL but isolate the ledger behind an internal service interface with account, transfer and pending-transfer operations. That interface is the same shape TigerBeetle exposes, so switching later is a backend change, not a rewrite.

Before committing, run this checklist:

  • Profile real account distribution: what share of transfers touch the top 10 accounts?
  • Model lock hold time in PostgreSQL under your network topology, not on localhost.
  • Decide asset scale and rounding per currency, and write them down.
  • Define the idempotency key and where it is generated.
  • Specify linked chains for every multi-leg flow.
  • Design the control-plane outbox and the reconciler that resolves stuck intents.
  • Pin the TigerBeetle version, track release notes, and rehearse an upgrade.
  • Wrap clients with your own deadlines because requests do not time out.
  • Keep TigerBeetle on a private network, with authorisation in your API tier.
  • Automate invariant checks: zero-sum per ledger and no stale pending balances.

Frequently Asked Questions

What is TigerBeetle and how is it different from PostgreSQL?

TigerBeetle is a purpose-built database for financial transactions. Its only objects are accounts and transfers, both fixed 128-byte records, and double-entry rules such as balance limits are enforced by the database itself. PostgreSQL is a general-purpose relational database where you design the schema, constraints and locking yourself. TigerBeetle is meant for the hot path of transaction processing, alongside a general-purpose database for metadata, not instead of one.

Can PostgreSQL be used as a double-entry ledger?

Yes. A common design uses accounts, transactions and append-only entries, with a deferred constraint enforcing that each transaction sums to zero, ordered SELECT FOR UPDATE locking, a CHECK constraint for overdrafts, and a unique key for idempotency. It is correct and flexible. The limit appears under contention, where a single hot account caps throughput at roughly one divided by the lock hold time, and retries at SERIALIZABLE can amplify load.

How do pending, post and void transfers work in TigerBeetle?

A pending transfer reserves an amount in the pending fields of both accounts without changing posted balances. A later transfer referencing pending_id either posts it, in full or in part, or voids it, releasing the reservation. It can optionally expire after a timeout in seconds. Each pending transfer can be resolved only once, and the original record is never modified, so the history stays append-only.

Is TigerBeetle safe enough for production money movement?

An independent Jepsen analysis published on 2025-06-06 tested versions 0.16.11 through 0.16.30 and found two safety bugs, both fixed, plus crash and availability issues. From 0.16.26 onward its findings matched the claim of strong serializability, and it praised resilience to disk corruption. It recommended 0.16.43 or later. Safety also depends on your own integration, so test failures, upgrades and reconciliation before trusting it.

Does TigerBeetle scale horizontally?

Not for throughput. It uses a single leader and executes transfers on a single core, because hot accounts defeat sharding. Adding replicas improves fault tolerance, not transfers per second. Throughput comes from batching, with up to 8,190 transfers per request, and from running logic inside the database. If you need more capacity than one core can deliver, you would partition by independent ledgers across separate clusters.

What is Viewstamped Replication?

Viewstamped Replication is a consensus protocol for replicated state machines. A primary orders requests and sends them to backups, committing when a quorum acknowledges; if the primary fails, a view change selects a new primary. TigerBeetle uses it so that failover is automatic and correct. Per its documentation, consensus costs matter mostly during failover, while normal operation pays only for replicating each batch.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *