Getting Started with TigerBeetle
Traditional databases handle financial transactions by wrapping business logic in application code. A transactions table, a balances table, SELECT FOR UPDATE to prevent race conditions, application-level checks for sufficient funds. This works until you need millions of transfers per second with zero tolerance for double-spending, lost transactions, or inconsistencies. Then you're fighting the database instead of building your product.
TigerBeetle is a financial transactions database designed for one thing: moving money between accounts correctly, at massive scale. Double-entry bookkeeping is a primitive operation. Every transfer debits one account and credits another atomically. The database enforces accounting invariants you'd normally code, test, and debug yourself. No SQL, no schema design, no ORM. Two operations: create accounts and create transfers. That's it.
We'll build a multi-party payment system in one TypeScript file: a merchant, a customer, a platform fee account, and a tax account, processing transfers that model a real payment flow. Everything works with a local TigerBeetle instance, or you can provision one on Layerbase Cloud and swap in the connection address.
Contents
- Create a TigerBeetle Instance
- Set Up the Project
- Connect to TigerBeetle
- Create Accounts
- Create Transfers
- Look Up Balances
- Two-Phase Transfers
- Linked Transfers
- Balance Limits
- When to Use TigerBeetle
- Wrapping Up
Create a TigerBeetle Instance
Local with SpinDB
One command with SpinDB. No Docker, no manual binary downloads. (What is SpinDB?)
Install SpinDB globally:
npm i -g spindb # npm
pnpm add -g spindb # pnpmOr run it directly without installing:
npx spindb create tiger1 -e tigerbeetle --start # npm
pnpx spindb create tiger1 -e tigerbeetle --start # pnpmIf you installed globally, create and start a TigerBeetle instance:
spindb create tiger1 -e tigerbeetle --startSpinDB downloads the TigerBeetle binary for your platform, configures it, and starts the server. Verify it's running:
spindb url tiger1127.0.0.1:3001Leave the server running. We'll connect to it from TypeScript in the next section.
Layerbase Cloud
Layerbase Cloud also offers managed TigerBeetle instances. The Quick Connect panel has the connection address you'll need.
Everything else in this guide works identically whether you're running locally or on Layerbase Cloud. Just swap in your connection details where you see 127.0.0.1:3001.
Set Up the Project
mkdir tigerbeetle-payments && cd tigerbeetle-payments
pnpm init
pnpm add tigerbeetle-node
pnpm add -D tsx typescriptCreate a file called payments.ts. All the code in this post goes into that one file.
Connect to TigerBeetle
TigerBeetle uses a custom binary protocol. No HTTP, no SQL. The client connects directly using a cluster ID and an address list.
import {
createClient,
AccountFlags,
TransferFlags,
} from 'tigerbeetle-node'
const client = createClient({
cluster_id: 0n,
replica_addresses: ['127.0.0.1:3001'],
})cluster_id is a BigInt identifying the cluster. For a local SpinDB instance, it's 0n. If you're using Layerbase Cloud, swap the address with yours from Quick Connect.
TigerBeetle uses BigInt throughout because account IDs, transfer IDs, and amounts are all 128-bit unsigned integers. You'll see the n suffix everywhere.
Create Accounts
Every account has an id, a ledger, and a code. The ledger groups accounts by currency or namespace (all transfers must stay within the same ledger). The code is application-defined, useful for distinguishing customers from merchants.
Four accounts for our payment system:
const LEDGER_USD = 1
const CODE_CUSTOMER = 1
const CODE_MERCHANT = 2
const CODE_PLATFORM = 3
const CODE_TAX = 4
const accountErrors = await client.createAccounts([
{
id: 1n,
ledger: LEDGER_USD,
code: CODE_CUSTOMER,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
flags: 0,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
timestamp: 0n,
},
{
id: 2n,
ledger: LEDGER_USD,
code: CODE_MERCHANT,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
flags: 0,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
timestamp: 0n,
},
{
id: 3n,
ledger: LEDGER_USD,
code: CODE_PLATFORM,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
flags: 0,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
timestamp: 0n,
},
{
id: 4n,
ledger: LEDGER_USD,
code: CODE_TAX,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
flags: 0,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
timestamp: 0n,
},
])
if (accountErrors.length > 0) {
console.error('Account creation errors:', accountErrors)
process.exit(1)
}
console.log('Created 4 accounts: customer, merchant, platform, tax')The id is a 128-bit unsigned integer (BigInt in JavaScript). In production you'd use UUIDs converted to 128-bit integers, but sequential IDs work fine for learning. The balance fields (debits_pending, debits_posted, etc.) initialize to 0n. TigerBeetle tracks them internally. The timestamp is server-set, so pass 0n.
Create Transfers
Every transfer is a double-entry operation: debit one account, credit another. Amounts always balance. You cannot create a transfer that adds money from nowhere or removes it into nothing.
A $100.00 payment from customer to merchant:
const transferErrors = await client.createTransfers([
{
id: 1n,
debit_account_id: 1n, // customer
credit_account_id: 2n, // merchant
amount: 10000n, // $100.00 in cents
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: 0,
timestamp: 0n,
},
])
if (transferErrors.length > 0) {
console.error('Transfer errors:', transferErrors)
process.exit(1)
}
console.log('Transferred $100.00 from customer to merchant')Amounts are integers. No floating-point anywhere. Store cents (or the smallest unit of your currency) to avoid rounding. The transfer's ledger must match both accounts. TigerBeetle rejects cross-ledger transfers.
Look Up Balances
After the transfer, let's check the balances:
const accounts = await client.lookupAccounts([1n, 2n, 3n, 4n])
for (const account of accounts) {
const names: Record<string, string> = {
'1': 'Customer',
'2': 'Merchant',
'3': 'Platform',
'4': 'Tax',
}
const name = names[account.id.toString()] ?? 'Unknown'
const debits = Number(account.debits_posted) / 100
const credits = Number(account.credits_posted) / 100
const balance = credits - debits
console.log(
`${name}: debits=$${debits.toFixed(2)}, credits=$${credits.toFixed(2)}, balance=$${balance.toFixed(2)}`,
)
}Customer: debits=$100.00, credits=$0.00, balance=$-100.00
Merchant: debits=$0.00, credits=$100.00, balance=$100.00
Platform: debits=$0.00, credits=$0.00, balance=$0.00
Tax: debits=$0.00, credits=$0.00, balance=$0.00Customer debits up $100.00. Merchant credits up $100.00. Total debits across the ledger equals total credits. That's the fundamental invariant of double-entry bookkeeping, and TigerBeetle enforces it at the database level. You cannot break it.
Two-Phase Transfers
Real payments rarely happen in one step. Swipe a credit card and the bank places an authorization hold (pending). The merchant later captures (posts) it. If they never capture, the hold expires and funds are released.
TigerBeetle models this with two-phase transfers. First, a pending transfer:
const pendingErrors = await client.createTransfers([
{
id: 100n,
debit_account_id: 1n, // customer
credit_account_id: 2n, // merchant
amount: 5000n, // $50.00
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: TransferFlags.pending,
timestamp: 0n,
},
])
if (pendingErrors.length > 0) {
console.error('Pending transfer errors:', pendingErrors)
process.exit(1)
}
console.log('Created pending transfer of $50.00')Now debits_pending increases by 5000, but debits_posted stays the same. Funds held, not moved. Verify:
const pendingCheck = await client.lookupAccounts([1n])
console.log(
`Customer pending debits: $${(Number(pendingCheck[0].debits_pending) / 100).toFixed(2)}`,
)Customer pending debits: $50.00Post (capture) the transfer by referencing its id as pending_id:
const postErrors = await client.createTransfers([
{
id: 101n,
debit_account_id: 1n,
credit_account_id: 2n,
amount: 5000n,
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 100n,
flags: TransferFlags.post_pending_transfer,
timestamp: 0n,
},
])
if (postErrors.length > 0) {
console.error('Post transfer errors:', postErrors)
process.exit(1)
}
console.log('Posted (captured) the $50.00 transfer')After posting, debits_pending returns to zero and debits_posted increases. Funds moved. To void instead of post (cancel an authorization hold), use TransferFlags.void_pending_transfer.
Linked Transfers
In real payments, money often flows to multiple parties at once. Customer pays $100.00, platform takes 10%, 5% goes to tax. All three transfers must succeed or fail together. If the tax transfer fails, you don't want the merchant getting money without the fee collected.
Linked transfers handle this. Set the linked flag on every transfer except the last in the chain:
const linkedErrors = await client.createTransfers([
{
id: 200n,
debit_account_id: 1n, // customer
credit_account_id: 2n, // merchant: $85.00
amount: 8500n,
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: TransferFlags.linked,
timestamp: 0n,
},
{
id: 201n,
debit_account_id: 1n, // customer
credit_account_id: 3n, // platform fee: $10.00
amount: 1000n,
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: TransferFlags.linked,
timestamp: 0n,
},
{
id: 202n,
debit_account_id: 1n, // customer
credit_account_id: 4n, // tax: $5.00
amount: 500n,
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: 0, // last in chain: no linked flag
timestamp: 0n,
},
])
if (linkedErrors.length > 0) {
console.error('Linked transfer errors:', linkedErrors)
} else {
console.log('Linked transfers succeeded: $85 merchant + $10 platform + $5 tax')
}One API call, three transfers. The first two have TransferFlags.linked set; the last closes the chain. If any transfer would fail (wrong ledger, nonexistent account, insufficient balance), all three are rejected. Fully atomic, no application-level transaction management.
Let's look at the final balances:
const finalAccounts = await client.lookupAccounts([1n, 2n, 3n, 4n])
console.log('\nFinal balances:')
for (const account of finalAccounts) {
const names: Record<string, string> = {
'1': 'Customer',
'2': 'Merchant',
'3': 'Platform',
'4': 'Tax',
}
const name = names[account.id.toString()] ?? 'Unknown'
const debits = Number(account.debits_posted) / 100
const credits = Number(account.credits_posted) / 100
console.log(
`${name}: debits=$${debits.toFixed(2)}, credits=$${credits.toFixed(2)}`,
)
}Final balances:
Customer: debits=$250.00, credits=$0.00
Merchant: debits=$0.00, credits=$185.00
Platform: debits=$0.00, credits=$10.00
Tax: debits=$0.00, credits=$5.00Notice that total debits ($250.00) equals total credits ($185.00 + $10.00 + $5.00 + the earlier $50.00 two-phase capture). The ledger always balances.
Balance Limits
TigerBeetle enforces balance constraints at the database level. The debits_must_not_exceed_credits flag prevents an account from spending more than it has received. Overdraft protection in the database, not your application code.
Create a balance-limited account and try to overdraft it:
const limitedAccountErrors = await client.createAccounts([
{
id: 10n,
ledger: LEDGER_USD,
code: CODE_CUSTOMER,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
flags: AccountFlags.debits_must_not_exceed_credits,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
timestamp: 0n,
},
])
if (limitedAccountErrors.length > 0) {
console.error('Limited account creation errors:', limitedAccountErrors)
process.exit(1)
}
console.log('\nCreated balance-limited account (id: 10)')This account has zero credits. Let's try to debit it:
const overdraftErrors = await client.createTransfers([
{
id: 300n,
debit_account_id: 10n,
credit_account_id: 2n,
amount: 1000n, // $10.00
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: 0,
timestamp: 0n,
},
])
if (overdraftErrors.length > 0) {
console.log('Overdraft correctly rejected:', overdraftErrors[0].result)
} else {
console.log('Transfer went through (unexpected)')
}Overdraft correctly rejected: exceeds_creditsRejected. Account 10 has zero credits and the flag is set. Fund the account first, then try again:
// Fund the limited account with $20.00
await client.createTransfers([
{
id: 301n,
debit_account_id: 1n, // from customer
credit_account_id: 10n, // to limited account
amount: 2000n, // $20.00
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: 0,
timestamp: 0n,
},
])
// Now try spending $10.00
const retryErrors = await client.createTransfers([
{
id: 302n,
debit_account_id: 10n,
credit_account_id: 2n,
amount: 1000n,
ledger: LEDGER_USD,
code: 1,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
pending_id: 0n,
flags: 0,
timestamp: 0n,
},
])
if (retryErrors.length === 0) {
console.log('Transfer of $10.00 succeeded after funding')
}Transfer of $10.00 succeeded after fundingIn PostgreSQL, you'd use a CHECK constraint or a trigger reading the balance inside a transaction. Both are fragile under concurrent writes. TigerBeetle handles it with one flag on account creation, correct even at millions of operations per second.
A Note on Performance
TigerBeetle is written in Zig and uses io_uring on Linux. A single node handles millions of transfers per second. It achieves this by batching operations and using a custom storage engine built specifically for financial state machines. No general-purpose query planner, no B-tree overhead, no WAL. The entire design is optimized for exactly two operations.
When to Use TigerBeetle
TigerBeetle is the right tool when your problem is moving value between accounts:
- Payment processing: charge, pay, collect fees, all in one atomic batch
- Ledger systems: auditable record of every value movement
- Wallet and balance management: prepaid accounts, loyalty points, in-app currencies with built-in overdraft protection
- Marketplace payments: multi-party splits where platform, seller, and tax authority each get a cut
- High-throughput financial pipelines: millions of transactions per second where correctness is non-negotiable
The contrast with PostgreSQL is telling. There you'd need: a transactions table, a balances table, SELECT FOR UPDATE, application-level funds checks, error handling to prevent double-spending, and retry logic for serialization failures. TigerBeetle replaces all of that with createAccounts, createTransfers, and a set of flags.
Wrapping Up
Under 150 lines. Accounts, transfers, two-phase commits, linked atomics, balance limits. That same pattern scales from a tutorial to a production system processing millions of transfers per second.
The TigerBeetle documentation covers time-based transfer timeouts, lookup by ID ranges, and cluster replication for fault tolerance.
To manage your local TigerBeetle instance:
spindb stop tiger1 # Stop the server
spindb start tiger1 # Start it again
spindb list # See all your database instancesTigerBeetle handles the ledger, but you still need a general-purpose database for user profiles, product catalogs, and everything else. SpinDB manages 20+ engines, so you can run TigerBeetle alongside PostgreSQL for app data and Redis for caching, all from one CLI.