Getting Started with TigerBeetle
Short version: npm i -g layerbase then lbase create tiger1 -e tigerbeetle --start gives you a local TigerBeetle, and lbase url tiger1 prints the host:port the Node client needs. Layerbase Desktop does the same in a GUI. TigerBeetle is pre-1.0 and pins its client to the exact server release, so treat this as a prototyping setup rather than a system of record.
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.
A heads-up before you dive in: TigerBeetle is pre-1.0 and still moving fast. The client and server have to run the exact same release, it has no built-in authentication, and details change between versions. Treat it as a sharp research tool for learning the model and prototyping, not yet a system of record. It is happiest run locally, or on a locked-down instance you control.
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 runs against a local TigerBeetle instance from the Layerbase CLI or Layerbase Desktop.
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
- FAQ
- Wrapping Up
Create a TigerBeetle Instance
Local with the Layerbase CLI
One command with the Layerbase CLI (formerly SpinDB). No Docker, no manual binary downloads. (What is the Layerbase CLI?)
Install the Layerbase CLI globally:
npm i -g layerbase # npm
pnpm add -g layerbase # pnpmOr run it directly without installing:
npx layerbase create tiger1 -e tigerbeetle --start # npm
pnpx layerbase create tiger1 -e tigerbeetle --start # pnpmIf you installed globally, create and start a TigerBeetle instance:
lbase create tiger1 -e tigerbeetle --startThe CLI downloads the TigerBeetle binary for your platform, configures it, and starts the server. Verify it's running:
lbase url tiger1127.0.0.1:3000Leave the server running. We'll connect to it from TypeScript in the next section.
Layerbase Cloud
TigerBeetle does run on Layerbase Cloud. It needs the Pro plan with at least one $10/mo pool block, and that requirement is the engine being honest about itself: TigerBeetle reserves roughly 1.4 GB of RAM around the clock and never sleeps, so it runs always-on in its own container instead of hibernating between connections the way most Layerbase databases do. That reservation is more than the base Pro pool absorbs on its own, which is why the pool block comes with it. The create flow states the same requirement before you commit to anything, and the engine carries an Alpha badge because TigerBeetle itself is pre-1.0.
A cloud instance behaves like the local one in this guide. The dashboard hands you the instance address and cluster id with copy-ready client snippets, so replica_addresses is the only line that changes. TigerBeetle ships no built-in authentication, so IP restrictions are mandatory on it: whitelist the addresses your app connects from before you point a client at it. There's also a built-in Ledger console for creating and looking up accounts and transfers without writing any code. Two things a TigerBeetle instance doesn't get: hibernation, for the reason above, and branching, because a TigerBeetle cluster keeps its identity inside its own data.
If you're here to learn the model, stay local. The Layerbase CLI (shown above) and Layerbase Desktop each give you a real TigerBeetle in one step, free, with the machine to itself. The rest of this guide works against either one: point your client at the host:port you were given.
Set Up the Project
mkdir tigerbeetle-payments && cd tigerbeetle-payments
pnpm init
pnpm add tigerbeetle-node@0.16.70
pnpm add -D tsx typescriptThe client and server versions have to match exactly, so pin tigerbeetle-node to the version your server runs. The Layerbase CLI and Layerbase Desktop both ship 0.16.70 today, and a newer client will refuse to connect (you will see a client_release_too_high eviction).
Create 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,
CreateTransferError,
} from 'tigerbeetle-node'
const client = createClient({
cluster_id: 0n,
replica_addresses: ['127.0.0.1:3000'],
})cluster_id is a BigInt identifying the cluster. For a local instance from the Layerbase CLI, it's 0n, and the address is whatever lbase url printed (port 3000 unless something else already had it).
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,
reserved: 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,
reserved: 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,
reserved: 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,
reserved: 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. reserved is padding in the wire format, but the client still insists on it: leave it out and createAccounts throws reserved must be a number before anything reaches the server.
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=$235.00
Platform: debits=$0.00, credits=$10.00
Tax: debits=$0.00, credits=$5.00The merchant's $235.00 is everything so far: the original $100.00, the $50.00 two-phase capture, and the $85.00 from the linked batch. Total debits ($250.00) equal total credits ($235.00 + $10.00 + $5.00). 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,
reserved: 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:',
CreateTransferError[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. result comes back as a number (54), which is why we index CreateTransferError to get the name out of it. 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.
FAQ
How do I run TigerBeetle locally?
npm i -g layerbase then lbase create tiger1 -e tigerbeetle --start. The CLI downloads the TigerBeetle binary for your platform, configures it, and starts the server; lbase url tiger1 gives you the host:port to hand the client. Layerbase Desktop does the same thing with a button.
Can I run TigerBeetle on Layerbase Cloud?
Yes, on the Pro plan with at least one $10/mo pool block. TigerBeetle reserves roughly 1.4 GB of RAM around the clock and never sleeps, which is more than the base Pro pool absorbs on its own, so the create flow asks for the pool block up front. A managed instance runs always-on in its own container, carries an Alpha badge because the engine is pre-1.0, and connects exactly like the local one here: same client code, different replica_addresses.
How does the client connect?
Over TigerBeetle's own binary protocol, not HTTP and not SQL. createClient takes a cluster_id and a list of replica addresses, and for a local CLI instance the cluster id is 0n. Account ids, transfer ids, and amounts are all 128-bit unsigned integers, which is why BigInt literals appear throughout.
Is TigerBeetle ready for production money?
Not yet, in our reading. It is pre-1.0, the client and server must run the exact same release, it ships no built-in authentication, and details still change between versions. Use it to learn the model and prototype, and keep a system of record elsewhere until it settles.
What replaces SQL transactions here?
Two operations and a set of flags. createAccounts and createTransfers cover the whole API, double-entry is the primitive rather than something you assemble, and linked transfers make a batch succeed or fail together. That replaces the transactions table, balances table, SELECT FOR UPDATE, funds checks, and retry logic you would otherwise write.
Wrapping Up
Under 150 lines. Accounts, transfers, two-phase commits, linked atomics, balance limits. Those are the same primitives real-world ledgers are built on, though TigerBeetle itself is still pre-1.0, so keep it to experiments and prototypes for now.
The TigerBeetle documentation covers time-based transfer timeouts, lookup by ID ranges, and cluster replication for fault tolerance.
To manage your local TigerBeetle instance:
lbase stop tiger1 # Stop the server
lbase start tiger1 # Start it again
lbase 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. The Layerbase CLI manages 20+ engines, so you can run TigerBeetle alongside PostgreSQL for app data and Redis for caching, all from one CLI.
Want a TigerBeetle instance without touching the command line? Layerbase Desktop gives you the same one-step setup in a GUI. And when you need one that isn't your laptop, Layerbase Cloud runs a managed TigerBeetle always-on in its own container, on the Pro plan with at least one $10/mo pool block.
Keep reading
- Managed TigerBeetle: where things actually stand in 2026TigerBeetle is purpose-built for double-entry financial accounting. Its own managed offering is invitation-only, but self-serve hosting now exists. The honest state of running it in 2026.
- Sleep vs Pause vs Archive: Why Free Databases Fail DifferentlyA sleeping database wakes on your next query, a paused project waits for a human to click Resume, and an archived one waits for you to ask for it back. The difference decides whether an idle week costs you three seconds or an afternoon of debugging the wrong layer.
- Hosting Postgres in Asia and the Middle EastLayerbase runs in the United States and that is where we are building first. If your users are in Asia or the Middle East and what you need is Postgres, put the database near them. We recommend Nearbase for that, and here is how the two products split the work.
- Preview environment platforms in 2026: what is in the database when the preview comes upEvery platform on this list will give a pull request its own URL. The question that sorts them is what is in the database behind that URL: nothing, a restore of last night's backup, or the live data as of right now. Here is where each of eleven platforms lands, quoted from their own docs, and what a preview costs while the PR sits open.