Skip to content

Getting Started with TigerBeetle

10 min readTigerBeetleDatabasesFinancial

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

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:

bash
npm i -g layerbase    # npm
pnpm add -g layerbase # pnpm

Or run it directly without installing:

bash
npx layerbase create tiger1 -e tigerbeetle --start  # npm
pnpx layerbase create tiger1 -e tigerbeetle --start # pnpm

If you installed globally, create and start a TigerBeetle instance:

bash
lbase create tiger1 -e tigerbeetle --start

The CLI downloads the TigerBeetle binary for your platform, configures it, and starts the server. Verify it's running:

bash
lbase url tiger1
text
127.0.0.1:3000

Leave 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

bash
mkdir tigerbeetle-payments && cd tigerbeetle-payments
pnpm init
pnpm add tigerbeetle-node@0.16.70
pnpm add -D tsx typescript

The 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.

typescript
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:

typescript
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:

typescript
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:

typescript
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)}`,
  )
}
text
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.00

Customer 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:

typescript
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:

typescript
const pendingCheck = await client.lookupAccounts([1n])
console.log(
  `Customer pending debits: $${(Number(pendingCheck[0].debits_pending) / 100).toFixed(2)}`,
)
text
Customer pending debits: $50.00

Post (capture) the transfer by referencing its id as pending_id:

typescript
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:

typescript
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:

typescript
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)}`,
  )
}
text
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.00

The 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:

typescript
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:

typescript
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)')
}
text
Overdraft correctly rejected: exceeds_credits

Rejected. 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:

typescript
// 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')
}
text
Transfer of $10.00 succeeded after funding

In 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:

bash
lbase stop tiger1    # Stop the server
lbase start tiger1   # Start it again
lbase list           # See all your database instances

TigerBeetle 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.