How to Scale a Ledger
From making the case for investment to the key attributes of best-in-class ledgers, Modern Treasury's technical whitepaper, "How to Scale a Ledger" is a must-read for any business that moves money.
Contents
Introduction
At Modern Treasury, we work with some of the largest fintechs and marketplaces in the US. We started with an API that lets companies integrate directly with bank payment rails. As our customers scaled over time, however, we quickly realized or they needed a way to track the high volumes of transactions and balances in their platforms.
Companies that move money need a ledger: a scalable, double-entry database for financial transactions and balances. It seems simple. But as developers who work with money know, and as the news will attest to, this is actually a complex computer science problem. This six-part series summarizes what we've learned helping companies implement ledgers that are reliable, consistent, and fast.
Why Use a Ledger Database?
As financial services increasingly become embedded into mobile and web apps, more companies are facing the challenge of storing, transferring, and converting money. Typically, most developers start by embedding financial logic directly into their domain models:
- The price of a ride share is a property of the ride
- The monthly payout to a gym is a sum across bookings
- The amount left to pay on a loan is stored on the loan object
Why Traditional Models Fail At Scale
As your user base grows and you embed financial services deeper into your product, four common problems arise:
- Reporting Needs Expand: Companies need more sophisticated financial reporting as they grow. Every movement must be immutably recorded, keeping track of the source and destination of each dollar for compliance and financial audits.
- Data Becomes Fragmented: Stitching together a clean history of financial events becomes impossible as data models become more complex, companies migrate to service-oriented architectures, or multiple fintech SaaS products that aren't natively integrated are introduced.
- Performance Slows Down: Performance degrades as the number of users increases: payout cron jobs miss deadlines, card authorization checks lag, and other bottlenecks emerge.
- Failure Scenarios Increase: Overcharging, insufficient funds, fraudulent transactions, and other reasons for rejected payments are more frequent and difficult to reverse without a clear audit trail.
All of these problems can be solved with a ledger database.
Core Features of a Ledger Database
At its core, a ledger database is a simple data model:
- Accounts: Represent discrete pools of value
- Transactions: Atomic monetary events that affect accounts
- Entries: Individual debits and credits that make up a transaction
A common refrain from engineers is: “A ledger is simple to build, but the business logic on top of a ledger is complex.” In fact, a well-designed and scalable core ledger database can simplify business logic and reduce time to market.
The Fintech Translation Problem
Product engineers speak the language of their domain models—orders, rides, and bookings. As their products scale, these engineers must translate another language from the finance team—credits, debits, assets, and liabilities. Invariably, engineers get assigned the dreaded task of translating our domain models into double-entry accounting.
This is no easy feat. Financial events are often:
- Fragmented and from disparate sources. The app may rely on multiple fintech SaaS solutions (like issuer processors, card processors, and ACH processors). App data models may be spread across multiple databases and be owned by different teams of engineers. Corporate bank accounts contain fungible cash—attributing each dollar to its source is not possible.
- Speaking different languages. Every fintech SaaS product has its own set of APIs that require separate integrations. App data models may be a mix of new and legacy systems, and documentation is likely scarce. As we know well at Modern Treasury, banks send transaction information in a wide variety of arcane formats that are difficult to parse.
- Generated from mutable underlying records. None of the sources of financial events have a rock-solid, consistent way to reconstruct why a pool of money was a certain value at a certain time.
Many companies that move money as part of their products take the translation approach, however. Finance teams learn to accept that reconciliation can only happen accurately at the aggregate level. Attributing money movement back to the source application event is always a manual task, and in some cases, isn't possible.
Real-World Consequences of Ledger Errors
Missing or inconsistent attribution can cause serious issues.
A product leader at a large consumer payments company recently told us that every month, the finance team would notice that a few million dollars had gone missing in their ledger, kicking off a mad scramble to figure out where the money had gone.
Every engineer that has built money moving software has a similar story. In these stories, it's not the funds that go missing—they're still sitting in a bank account. It's the attribution that's lost—the database record of who they actually belong to.
Even worse, these ledger errors ultimately become public:
- A small business customer on a marketplace platform will ask questions when a sale they expected to appear in a payout isn't there.
- A digital wallet customer will churn if they send money to another customer, but it never arrives.
- Regulators will ask hard questions if a loan customer initiates an on-time payment, but your system marks them as past due because the payment was recorded late.
The solution is to enforce double-entry and immutability at the source of financial events.
Just like a unique index in a relational database gives engineers confidence, a ledger database that enforces double-entry rules makes it not just difficult for money to go missing, but architecturally impossible.
Why Most Companies Delay, and Why You Shouldn't
Why doesn't every company that moves money use a double-entry ledger to power their products? Because building a ledger that both follows double-entry rules and is performant enough to run live apps is a difficult engineering problem.
Building a scalable, reliable, and performant double-entry ledger system takes:
- Years of effort and dozens of senior engineers to migrate single-entry systems
- Deep domain knowledge of accounting and applying that to systems architecture
- Ongoing maintenance and reconciliation tooling
Only recently has it been possible for companies to get the benefits of double-entry accounting without a massive up-front build and costly maintenance by using purpose-built ledger databases like Modern Treasury Ledgers.
Mapping Financial Events to Double-Entry Primitives
Every financial event—whether a user deposit, a loan repayment, or a card swipe—can be abstracted into a simple, reliable structure. This chapter outlines how to model money movement through the core primitives of a ledger. This section focuses on the core fields and won't be exhaustive of every feature available in the Ledgers API. See the documentation for a complete reference.
Adding Double-Entry to Financial Events
What does it take for a ledger to be immutable and double-entry at the source of financial events? At the most basic level, it means:
- Every monetary event is logged using consistent, double-entry data model.
- All balances shown to users and systems are read from this data model.
All financial flows—from credit card authorizations to crypto on-ramps—can be modeled with three core objects: Accounts, Entries, and Transactions.
Account: The Sum of All Balances
An Account represents a discrete pool of value, and tracks a sum of money, denominated in a currency. Examples of Accounts include a digital wallet, a company's operating cash, and loan principal.
An Account represents a discrete pool of value, and tracks a sum of money, denominated in a currency. Examples of Accounts include a digital wallet, a company's operating cash, and loan principal.
Money moves between Accounts instantly within a ledger, even when real-world money movement happens asynchronously. For example, money sent via ACH takes at least a day to show up in a recipient's bank account. Because real money movement can't happen instantly, Accounts should be able to report a few different balances:
- Posted balance: fully settled funds
- Pending balance: fully settled funds plus posted funds (the money that's expected to settle)
- Available balance: funds available to send out (does not include expected outgoing funds nor incoming unsettled funds)
Not all ledgers model posted, pending, and available balances; many require developers to model these balances separately as separate Accounts. We believe that application ledgers should have these core primitives, as they reflect fundamental concepts in payment flows and must be easily accessible and queryable.
Example: Credit Card Lifecycle
Let's follow an example of a credit card to see how these balances are affected by different actions in the Ledgers API.
| EVENT ID | EVENT | POSTED BALANCE | PENDING BALANCE | AVAILABLE BALANCE |
|---|---|---|---|---|
| 1 | A credit card starts with a $10,000 credit limit. | $10,000 | $10,000 | $10,000 |
| 2 | A card is swiped to purchase a $1,000 plane ticket. This purchase starts out pending. | $10,000 | $9,000 | $9,000 |
| 3 | That night, the purchase settles. | $9,000 | $9,000 | $9,000 |
| 4 | The card holder initiates a $1,000 card payment from their bank account. | $9,000 | $10,000 | $10,000 |
| 5 | The card payment completes. | $10,000 | $10,000 | $10,000 |
| 6 | A hotel places a $250 hold on the card at the beginning of the stay, for incidentals. | $10,000 | $9,750 | $9,750 |
| 7 | The $250 hold is removed at the end of the stay. | $10,000 | $10,000 | $9,000 |
Credit card example
We report these three balances separately so that the customer knows how much can be spent from an Account at any given time. Depending on the use case and risk tolerance, an app may choose to use each balance for a different purpose. For example, a customer may be limited by their available balance for transfers out of their Account, but their past-due status for a loan may be optimistically determined by their pending balance.
Entry: An Immutable Record of Movement
Account balances are never directly modified. Instead, changes in balances are recorded as entries written to the Account. Entries are a complete log of balance changes into and out of an account, and include:
- Immutable core fields (
amount,direction, etc.) - A mutable
discarded_atfield (used only for pending reversals, and set when an Entry is replaced by a later Entry)
1. A credit card starts with a $10,000 credit limit on the account.
2. A card is swiped to purchase a $1,000 plane ticket. This purchase starts out pending on the card account.
3. That night, the purchase settles.
4. The card holder initiates a $1,000 card payment from their bank account.
5. The card payment from the bank account completes.
6. A hotel places a $250 hold on the card at the beginning of a stay.
7. The $250 hold is released at the end of the stay.
Discarding Entries
Only pending Entries can be replaced; posted and archived Entries are permanent. Pending Entries get replaced in the following two circumstances:
Why introduce this mutability in the API? To see why, it helps to consider the alternative. Instead of discarding Entries, we could create a reversal Entry that undoes the original Entry. In this model, moving an Entry from pending to posted would create two Entries instead of just one.
In that case, step five above would instead be:
While valid, this approach leads to a messy Account history. We can't distinguish between debits that were client-initiated and debits that are generated by the ledger as reversals. Listing Entries on an Account doesn't match our intuition for what Entries comprise the current balance of the Account.
Discarding provides a clean, audit-safe method of updating pending movements without compromising history. You can see the current Entries by excluding any Entries that have discarded_at set.
Computing Account Balances
Now that all balance changes are logged as Entries, how do we compute Account balances? Here's where the normal_balance field on Account comes into play. This property defines how balances should respond to debit and credit entries.
Every Account in a ledger is categorized as debit normal or credit normal:
- Debit-Normal Accounts represent uses of funds (assets, expenses)
- Debit Entries increase the balance; credit Entries decrease it
- Credit-Normal Accounts represent sources of funds (liabilities, equity, revenue)
- Credit Entries increase the balance; debit Entries decrease it
Why bother with debits, credits, and Account normality at all? Many ledgers try to avoid complications by using negative numbers to represent debits and positive numbers to represent credits. At first glance, this appears to align better with engineers' intuitions. But we chose to include Account normality, because without it, double-entry accounting is messy.
Consider a simple flow where a user deposits money into a digital wallet. This flow will affect the company's cash Account and the user's wallet Account. Our intuition is that the cash Account will increase (the company got cash from the user), and also the wallet Account will increase (the user has a positive balance in the digital wallet).
With a positive/negative number approach, it is impossible for both Accounts to increase. We have to pick one Account to be negative (so that no money is created or destroyed), and it's not clear to which we should apply negative numbers.
Credits and debits solve this problem. We should debit the cash Account, and its balance increases because it is debit normal. And we should credit the user's wallet Account, and its balance also increases because it is credit normal. This mirrors real-world accounting logic and supports scale.
This digital wallet scenario is just one example. For a full primer on double-entry accounting, check out our series, Accounting for Developers.
Core Fields for Balance Calculation
Now, we'll focus on how to actually implement a double-entry ledger. With some simple math, each type of balance (pending, posted, and available) can be calculated from the following five fields:
posted_debits:sum of posted debit Entriesposted_credits:sum of posted credit Entriespending_debits:posted_debits plus the sum of non-discarded pending debit Entriespending_credits:posted_credits plus the sum of non-discarded pending credit Entriesnormal_balance:One of credit or debit, stored on the Account
A ledger database should be optimized to retrieve these five fields quickly, and only compute posted balance, pending balance, and available balance upon request.
Each balance type is then computed as follows:
Posted Balance
case normal_balance
when "credit"
posted_credits - posted_debits
when "debit"
posted_debits - posted_credits
endPending Balance
case normal_balance
when "credit"
pending_credits - pending_debits
when "debit"
pending_debits - pending_credits
endAvailable Balance
case normal_balance
when "credit"
posted_credits - pending_debits
when "debit"
posted_debits - pending_credits
endDouble-entry starts with clean modeling. Mapping all financial actions to Accounts, Entries, and Transactions ensures consistency and traceability across all balances. Developers should treat the ledger as the source of financial truth by writing every balance-affecting event directly to this data model.
Transaction Models for Atomic Money Movement
Why Transactions Matter in Ledger Systems
While Accounts and Entries within ledger databases provide an immutable audit trails of balance changes, they aren't enough on their own. Without a Transaction object, it's possible to create inconsistent states if only part of a money movement is recorded.
Because most ledgers excel in only one use case, with any new business or new products, rather than scale their ledger, companies tend to build multiple ledgers that aren't interoperable with each other. The most powerful ledgers—like Modern Treasury's ledger—can operate in both models, allowing clients to choose at the Entry level which guarantees they need, depending on performance and consistency requirements.
Transaction Schema: Digital Wallet Transfer Example
Consider a simple transfer of money between accounts in a peer-to-peer wallet app. Let's say Bobby sends Alice $10; we can represent that transfer as two Entries.
Imagine that bobby_entry was successfully written, but the corresponding alice_entry failed to write (maybe the database was having network issues). Now the ledger is in an inconsistent state—Bobby was debited money, but Alice didn't get anything. Money is lost.
Transactions solve this consistency problem by allowing us to specify groups of Entries that either must all succeed or all fail. In order to guarantee atomicity, all the non-discarded Entries on a Transaction must share the status of the Transaction. This ensures that all Entries progress in status at the same time, all-or-nothing.
A Ledger API should only allow clients to directly create Transactions, not Entries. This limitation helps ensure clients don't run into consistency problems. However, that means a Ledger API must manage creating Entries itself. There are three operations to implement, corresponding to the possible states of the Transaction:
- Pending (initial state)
- Posted (finalized)
- Archived (canceled before posting)
Creating a Pending Transaction
This is generally the first step in the lifecycle of a Transaction. The system persists the debit and credit Entries as pending, but the transaction is not finalized.
Posting a Transaction (Finalizing It)
Since Entries are immutable, when we move a Transaction from pending to posted, we need to:
1. Discard the pending Entries (bobby_entry_1 and alice_entry_1).
2. Create new posted Entries. This preserves immutability: posted Entries are never modified.
Archiving a Transaction (Canceling It)
Posted Transactions are immutable, and so cannot be archived. Pending Transactions can have their status changed to archived, following a similar process to posting.
1. Discard pending bobby_entry_1 and alice_entry_1.
2. Create new Entries with archived status.
Developer Benefits of a Transaction Model
Prevents imbalanced Entries and accidental money creation or discussion
Transaction-level traceability improves debugging and compliance
Enables encapsulation of complex money flows (e.g., transfers, refunds, settlements)
A Ledger API that has a transaction model gives you confidence in the system's integrity and allows you to build financial workflows that scale.
Writing to the Ledger for Recording and Authorization
Most ledgers implementations we've seen can operate at scale in one of the following two use cases:
Because most ledgers excel in only one use case, with any new business or new products, rather than scale their ledger, companies tend to build multiple ledgers that aren't interoperable with each other. The most powerful ledgers—like Modern Treasury's ledger—can operate in both models, allowing clients to choose at the Entry level which guarantees they need, depending on performance and consistency requirements.
Recording
Recording means capturing financial events that occur in external systems (your bank, payment processors, card networks), translating them into the core data models, and making them available for query by both internal and external customers. It is characterized by:
Architecture
The Application Layer sits between the Event Source and the Ledger Database and Domain Object Database.
Maintaining Consistency
Notice that the Domain Object DB and the Ledger DB are dotted-lined from the Application Layer, indicating that this connection can be asynchronous and eventually consistent. This eventual consistency enables high throughput.
You may be asking, “How can I move money confidently with eventual consistency?” You can keep a ledger internally consistent even if it is a delayed representation of money movement, by supporting client-supplied timestamps and Account balance versions.
Since money movement already occurred by the time it's recorded by the ledger, clients need a way to specify when the money movement actually happened:
The effective_at timestamp lets the client backdate a transaction to when it actually occurred.
To preserve atomicity, all Entries on a Transaction inherit the Transaction's effective_at timestamp.
The effective_at field allows clients to modify historical balances, so that they reflect the balances as they were in the external system. Using effective_at, we can also support querying historical balances to ensure transactions appear in the correct order.
Typically, the effective_at timestamp sent by the client will be close to the created_at timestamp set by the ledger. The difference is the delay between getting information from the external system into the ledger—usually on the order of seconds or minutes.
Account Balance Versions
Allowing clients to modify historical balances introduces a problem: if balances in the past can change at any moment, how can we know which transactions correspond to a balance? Consider this race condition:
- At time t: A client with a stored value wallet reads customer Annie's account balance
- At time t+1: A Transaction transaction_1 is written to the ledger with effective time t-1
- At time t+2: The client asks for all Transactions before T. The result will include transaction_1, but the balance read will not. The client will see an inconsistency between the Transactions and the Account balance.
The Ledgers API allows for consistent Transaction and Account balance reads through fields on Account and Entry.
With these new fields, we can know exactly which Entries correspond with a given Account version. After reading the Account posted_balance at the effective time 2025-08-20T18:22:14+00:00, if the Account version is 10, the Entries that correspond to that balance can be found by filtering Entries by:
account_id- Status of
posted effective_atless than or equal to2025-08-20T18:22:14+00:00account_versionless than or equal to 10- Not discarded
Recording Use Cases
- Displaying Account Details: Account UIs for digital wallets, cards, brokerages, and other account-like products typically have a UI where the balance on an Account is displayed along with recent Entries. Using Entry
account_versionfield, we can ensure that the displayed balance and Entries are always consistent. - Payouts: Marketplaces collect money on behalf of their customers, and pay that money out on a certain cadence (daily, weekly, monthly). These systems must tolerate a few seconds of staleness, because actual money movement is happening through payment processors outside of the ledger. Using
effective_at, the ledger ensures that Transactions are in the correct order, even if they are recorded out of order. Account versions enable displaying to a user exactly which Entries correspond to a particular payout. - Loan Servicing: Systems that service loans are complicated, but generally work asynchronously. Accruing interest is similar to a marketplace payout—take a snapshot of a past-due balance, get the Account version, and accrue interest based on the Entries that comprise that balance. Applying a payment to a balance benefits from client-supplied timestamps—these systems should compute past due status based on when a payment was processed, not when the lending ledger recorded the payment.
- Crypto: By definition, crypto transactions happen outside of your application's ledger on a blockchain. Often it makes sense to keep a local copy of those transactions in a ledger database for performance reasons. Because the transactions have already happened by the time they are written to the application ledger, they should be recorded with an
effective_attimestamp matching when the transaction happened on the blockchain.
Authorizing means the ledger actively approves or denies Transactions based on Account states. It's characterized by:
This model is crucial when moving money in real-time (e.g., validating that a user has sufficient funds to process a transaction). Enforcing approval rules on each Transaction requires a strict ordering of Transactions, which also means that Transactions can only be processed one at a time. This limitation results in higher-latencies because Transaction recording can no longer be done in parallel on individual Accounts.
There are two types of rules that the Ledgers API supports, one based on Account versions and another based on Account balance.
Version Locking
Version locking is the simplest control that we can enforce while writing a Transaction to prevent out-of-order updates. Since account_version is updated every time an Entry is written to the Account, we can ensure writing order by having the client send an Account version along with the request to create a Transaction. The ledger will reject the write if the current Account version in the database is different from the version sent by the client.
This approval rule is similar to the concept of optimistic locking. We can enforce the rule at the database level using transactions following this algorithm:
Balance Locking
Version locking is susceptible to “hot accounts” (this is what we call Accounts that see a high volume of writes). If the account_version is incrementing at a fast enough pace, some Transactions will never be able to commit.
To solve this problem, it helps to step back and think about the main use case for locking in the first place. In almost all cases, clients want version locking in order to enforce balance assertions. For example, the client might want to create a pending Transaction for a card authorization only if there is enough available balance on the card.
To address the most common use case for locking, the Ledgers API supports balance locking at the Entry level. To implement this, we add a few new fields to the Entry data model: pending_balance_amount and BalanceCondition.
The pending_balance_amount details conditions that must be true on the Account's pending balance after the Transaction commits.
The BalanceCondition fields specify what needs to be true about the account balance for the Transaction to commit.
These new fields allow us to implement balance assertions when writing Transactions. Consider an Account with $1,000 available balance, with two Transactions written simultaneously on the account, one for $250 and one for $750. With version locking, the flow would be:
- $250 Transaction
- Read Account balance and version, and check that balance is greater than or equal to $250.
- Write Transaction that includes an Entry on the Account with the version read in the previous step.
- $750 Transaction
- Read Account balance and version, and check that balance is greater than or equal to $750.
- Write a Transaction on the Account, which fails because the $25 Transaction was written slightly before
- Again read Account balance and version
- Write Transaction
In total, we made a call to the ledger six times.
With balance locking, we can reach the same output in two calls:
- $250 Transaction
- Write Transaction with
gte: 0on the relevant Entry.
- Write Transaction with
- $750 Transaction
- Write Transaction with
gte: 0on the relevant Entry.
- Write Transaction with
Not only are we calling the ledger fewer times, but also the API better matches our intent to prevent a certain Account balance from going negative.
Implementing balance locking so that race conditions are handled properly and all possible combinations of locks on different balances are respected is beyond the scope of this paper. The logic is based on how version locking works—within a database transaction, attempt to write each Entry finding an Account with the required balance, and then check whether the update actually went through, rolling back the database transaction if it did not.
Authorizing Use Cases
- Digital Wallets: A digital wallet holds a sum of money for a user that can be withdrawn into a bank account (typically by initiating an ACH credit). It's important that digital wallets ensure that users have sufficient funds to initiate a withdrawal, which necessitates balance locking. Additionally, many digital wallets support closed-loop payments between users. Authorizing Entries enable such instant payments while making sure Account balances can't go below 0.
- Card Authorizations: In order to respond to a card authorization request, a ledger must be able to know the exact available balance of an Account at a point in time. Balance locking enables this while also preventing double-spending. Implementations can reserve the authorized amount until it is cleared using pending Transactions.
Mixed-Mode Ledgers: Automatically Recording or Authorizing
As we said above, most ledgers we've worked with implement only one of the recording or authorizing models. Because the guarantees and use-cases for each model are so different, it's easy to see why.
As companies add new products that need recording or authorizing ledgers, they build new ledgers that are optimized for each product. The downside of this approach is that it's an expensive strategy, as it requires staffing and maintenance overhead.
We've worked with some ledger implementations that get closer to being general purpose by designating certain Accounts as exclusively recording or authorizing. Some ledger implementations even allow different modes to be toggled by on-call engineers—if an Account is experiencing high Transaction throughput, an on-call engineer can put the Account into recording mode. What we've realized by working with companies using ledgers for many use cases is that even an Account settings implementation is not sufficient. For the Ledgers API to flex to be truly multi-purpose and multi-product, the model must be determined at the Entry level.
Immutability and Double-Entry
Guarantee: Every state of the ledger must be recorded and can be easily reconstructed.
Best-in-class ledger systems support both recording and authorizing use cases. Developers can design systems that read asynchronously from external sources or enforce balance rules in real time—on a per-Entry basis. Ledgers that support both unlock flexibility across product needs and performance requirements.
Immutability is the most important guarantee from a ledger. You may be wondering how immutability can be guaranteed if ledgers allow mutable fields:
- Accounts: The balances change as Entries are written to them.
- Transactions: The state can change from pending to either posted or archived. While the Transaction is pending, the Entries can change too.
- Entries: Entries can be discarded.
The Immutable Log Underneath
These mutable fields on our core data model help the ledger match real world money movement. But ultimately, the data model is built on top of an immutable, append-only log. All changes are preserved, and any previous state can be reconstructed through: This log can be queried in a few different ways to reconstruct past states through:
Querying Past States
We've already covered how effective_at allows us to see what the balance on an Account was at an earlier time. We can also see the Entries that were present on an Account at a timestamp, since no Entries are ever actually deleted. The Entries on Account account_a at effective time timestamp_a can be fetched by filtering:
WHERE account_id = 'account_a'
AND effective_at <= 'timestamp_a'
AND (discarded_at IS NULL OR discarded_at >= 'timestamp_a')The version field in the ledger API tells the current transaction version and can show previous states.
Using Versions for Precision
As we've seen before, timestamps may not be sufficient to allow clients to query all past states in ledgers that are operating at scale:
- Entries can be written at any point in the past using the
effective_attimestamp - Entries may share the same
effective_ator discarded_at timestamp
Ledgers should solve the drawbacks of timestamps by introducing versions on the main data models: Accounts and Transactions. Account versions that are persisted on Entries allow us to query exactly which Entries correspond to a given Account balance.
Since the Entries on a Transaction can be modified until the Transaction is in the posted state, we need to preserve past versions of a Transaction to be able to fully reconstruct past states of the ledger. The Ledger API stores all past states of the ledger with another field on Transaction:
The version field in the ledger API tells the current transaction version and can show previous states.
Transactions report their current version, and past versions of the Transaction can be retrieved by specifying a version when querying.
Past Transaction versions are used when trying to reconstruct the history of a particular event that involves multiple changing Accounts.
Example: A Bill-Splitting App
Let's consider a bill-splitting app. A pending Transaction represents the bill when it's first created, and users can add themselves to subsequent versions of the bill before it is posted:
Version 0: Chuck adds himself to the bill. Since he is the only party to the bill, the entry shows him paying for the whole amount.
Version 1: Dani wants to split costs. Now the bill amount is split between entries on Chuck's account and Dani's account.
Version 2: Elio is the final addition to split the bill three ways.
Version 3: The bill is posted.
Audit Logs
We've recorded past states of the main ledger data models, but we haven't recorded who made the changes. As ledgers scale to more product use cases, it's common for the ledger to be modified by multiple API keys and by internal users through an admin UI. We recommend an audit log to live alongside the ledger to record that kind of information.
Our Ledgers API audit logs contain these fields to show what was changed, by whom, and when.
Guarantee: All money movement must record the source and destination of funds.
Once a ledger is immutable, the next priority is to make sure money can't be created or destroyed. We do this by validating that every Transaction has:
- At least two entries, one debit and one credit
- Debits and credits equal each other, per currency
We'll focus just on the implementation details here—if you want a primer on how to use double-entry accounting, check out our guide, Accounting for Developers.
Why Currency-Level Balancing Matters
Double-entry accounting must be enforced at the application layer. When a client requests to create a Transaction, the application validates that the sum of the Entries with direction: credit equals the sum of the Entries with direction: debit. But what happens when a Transaction contains Entries from Accounts with different currencies?
Imagine a platform that allows its users to purchase and manage cryptocurrency. Let's say Freya buys 1 ETH and the platform wants to represent that purchase as an atomic Transaction.
Here's one way to structure that:
One way to structure a transaction for an ETH purchase.
This Transaction involves four accounts, two for Freya (one in USD and one in ETH, credit-normal), and two for the platform (one in USD and one in ETH, debit-normal).
This is a valid Transaction, and the credit and debit Entries still match, using the original summing method.
However, the original method breaks down when the Entries across currencies match, but the Transaction isn't balanced. Here's an example:
One way to structure a transaction for an ETH purchase.
Even though 1+1=2, this Transaction is crediting a fraction of ETH (created out of nowhere), and Freya was debited $1 (which disappeared).
This is a valid Transaction, and the credit and debit Entries still match, using the original summing method.
The correct validation is to group Entries by currency and validate that credits and debits for each currency match. You might think that because Freya spends $4,586.51 to buy one ETH, we could implement currency conversion Transactions with just two entries—one for USD, and one for ETH—and that they could balance based on the exchange rate of USD to ETH. There are three main issues with that implementation:
Immutability and double-entry are two of the four guarantees of a scalable ledger. In the following chapter, we'll dig into the other two: concurrency controls and performance.
Example: Card Authorization
Even processing simple events—authorization and clearing—requires a mix of authorizing and recording. Card authorizations require a synchronous read of the Account balance. After a successful authorization, the card network will send a clearing event to indicate that the reserved funds should be marked as finalized. This event should be recorded by the ledger regardless of the Account state.
We can infer whether an Entry needs to be recorded or authorized based on whether the Entry contains an Account version or balance lock. Transactions may contain a combination of locked and not locked Entries. For example, here's a sample card authorization Transaction:
Both Entries must succeed or fail atomically—even with different consistency and throughput requirements.Recorded Entries should be processed asynchronously and in batches, but also should not be written if their containing Transaction was not authorized. Additionally, they should not be present in reads from the ledger until the containing Transaction is authorized.
This level of complexity is what makes building double-entry ledgers difficult. Single-entry systems skip these guarantees at the expense of data integrity. It would be easy for a system to accidentally approve a card authorization, but not include that authorization in a daily settlement with the issuer processor. With double-entry, that is not possible.
The Best Ledgers Are Both/And
Best-in-class ledger systems support both recording and authorizing use cases. Developers can design systems that read asynchronously from external sources or enforce balance rules in real time—on a per-Entry basis. Ledgers that support both unlock flexibility across product needs and performance requirements.
Concurrency Controls, Performance, and More
Guarantee: It's not possible to unintentionally spend money, or spend it more than once.
As usage on your app scales, race conditions become a threat; however, concurrency controls in the Ledgers API prevent inconsistencies.
We've already covered how our Entry data model, with version and balance locks, prevents the unintentional spending of money (see Part IV). But nothing we've designed so far prevents accidentally double-spending money.
Idempotency Keys
How can the ledger detect that GoodPay is retrying rather than actually trying to move money more than once? The solution is deduplicating requests using idempotency keys.
An idempotency key is a string sent by the client. There aren't any requirements for this string, but it is the client's responsibility to send the same string when it's retrying a previous request.
Here's some simplified client code that's resilient to retries:
idempotency_key = generate_uuid()
response = None
while response is None or not response.is_successful():
response = create_transaction(idempotency_key)Notice that the idempotency key is generated outside of the retry loop, ensuring that the same string is sent when the client is retrying. On the ledger side:
- They keys must be stored for 24 hours to handle transient timeout errors (when it sees an idempotency key that's already stored, it returns the response from the previous request).
- It's the client's responsibility to ensure that no request that moves money is retried past a 24-hour period.
Guarantee: Retrieving the current and historical balances of an Account is fast.
Our ledger is now immutable, balanced, and handles concurrent writes and client retries. But is it fast?
You may have already noticed in the “Entry” section above that calculating Account balances is an O(n) operation, because we need to sum across n Entries.
That's fine with hundreds or thousands of Entries, but it's too slow once we have tens or hundreds of thousands of Entries. It's also too slow if we're asking the ledger to compute the balance of many Accounts at the same time.
We solve this problem by caching Account balances, so that fetching the balance is a constant time operation. We can compute all balances from pending_debits, pending_credits, posted_debits, and posted_credits—so those are the numbers we need to cache.
We propose two caching strategies, one for the current Account balance and one for balances at a timestamp.
Current Balances
The current Account balance cache is for the balance that reflects all Entries that have been written to the ledger, regardless of their effective_at timestamps. This cache is used for:
Because this cache is used for enforcing balance locks, it needs to be updated synchronously when authorizing Entries are written to an Account. This synchronous update is at the expense of response time when creating Transactions.
So that updates to the balance aren't susceptible to stale reads, we trust the database to do the math atomically. Let's use this example Entry to show how to update the cache:
The entry contains a posted debit of $100, so we atomically increment the pending_debit and posted_debit fields in the cache when it's written.
We also need to update the cache when a Transaction changes state from pending to posted or archived.
- If we archived a pending debit of $100, we'd decrease
pending_debitsby $100 in the cache. - Similarly, if the pending debit was posted, we'd increase
posted_debitsby $100.
Effective Time Balances
We also need to support fast reads for Account balances that specify an effective_at timestamp. This caching is more complex, so many ledgers avoid it by not supporting effective_at timestamps at all, at the expense of correctness for recorded Transactions.
We've found a few methods that work, and we'll cover two approaches here:
Our cache saves anchor points for balance reads. For every date that has Entries, the cache can report the end-of-day balance in constant time. The effective date cache should store the numbers that are needed to compute all balances.
Let's assume we've cached the balance for 2025-07-31, we can get the balance for 2025-08-01T22:22:41Z with this algorithm:
- Get the cache entry for an
effective_datethat's less than or equal to2025-07-31. Storelatest_effective_dateas the effective date for the cache entry found. - Sum all Entries by direction and status for latest_effective_date
<= entry.effective_at <= '2025-08-01T22:22:41Z' - Report the Account balance as the sum of step 1 and step 2.
Keeping the cache up-to-date is the bigger challenge. While we could update the current Account balance cache with one-row upsert, for the effective date cache we potentially need to update many rows: one for each date between the effective_at of the Transaction to be written and the latest effective date across all Entries written to the ledger.
Since it's not possible to update the cache synchronously as we write Transactions, we update it asynchronously. Each Entry is placed in a queue after the Transaction is initially written, and we update the cache to reflect each Entry in batches, grouped by Account and effective date.
What happens if a client requests the balance of an Account at an effective time, but recent Entries have not yet been reflected in the cache?
We can return an up-to-date balance by applying Entries still in the queue to balances at read time. Ultimately, an effective_at balance is:
balance = cached_by_date_balance + intraday_entries_sum + queued_entries_sum
Each entry in the cache stores the resulting balance on the associated Account after the Entry was applied.
The cache is simpler now, but updating it is more expensive.
When an Entry is written with an earlier effective_at than the max effective_at for the Account, all cache entries for Entries after the new effective_at need to be updated.
A detailed discussion of how the cache is updated is beyond the scope of this paper. It's not possible to update every cache entry atomically in the database, so each cache entry needs to keep track of how up to date it is.
Monitoring Cache Drift
Reading Account balances from a cache improves performance, but it means that balance reads may diverge from source-of-truth Entries; it's also possible for bugs to cause this divergence.
We handle this by:
Even with a ledger that is immutable, double-entry balanced, concurrency-safe, and fast to aggregate balances, your architecture may start to show limits under real-world pressures of product complexity and transaction volume.
Here are four major areas of improvement we've invested in for our Ledgers API that a truly global, scalable ledger management process will need:
1. Account Aggregation for Reporting and UX
For many products and reporting needs, aggregating at the Account level is not sufficient. For example:
- Reporting: Showing business-level metrics would mean combining Transactions and balances for all revenue Accounts.
- Product Experience: Supporting sub-accounts, our Account groupings that, from your user's perspective, share a balance and Transactions.
Solution: Account Categories (Graph-Based Aggregation)
Rather than flattening data structures or enforcing strict hierarchies, Modern Treasury Ledgers supports a graph model called Account Categories, which allows flexible aggregation across any group of Accounts.
This enables features like:
- Parent-child Account hierarchies
- Account-based permissioning
- Flexible rollups for balance and transaction reporting
2. Flexible Transaction Search
From generating Account statements to querying recent activity in a marketplace or brokerage platform, search is a core user requirement. Common search patterns include:
- Date-range filters for statement generation
- Fuzzy matching (e.g., allowing card customers to search Transaction history for a restaurant name versus the precise statement description)
- Custom sorting
Solution: Ongoing Tuning
Performant search is a constantly moving target as the volume of Transactions increases and the types of queries change. Some scaling tips include:
- Optimizing indexes based on query shape
- Partitioning high-volume tables
- Implementing pagination via cursor-based (not offset-based) methods
- Monitoring search latency
3. High-throughput Queueing for Writes
Leading fintechs easily surpass 5,000 QPS on their ledger infrastructure. At this scale, synchronous writes become a bottleneck.
Solution: Write-Ahead Queues
Implement an asynchronous Entry processing queue to buffer and batch ledger writes. Queues must be durable, idempotent, and designed to accept unlimited QPS for writes. Benefits of queueing include:
- Absorbing load spikes without dropping writes
- Decoupling application logic from DB write performance
- Supporting retries and monitoring independently
4. Account Sharding Across Databases
At a global scale, the throughput and data storage requirements of a ledger can't be accommodated by a single database. Some teams scale their ledger by migrating to an auto-sharding database like Google Spanner or CockroachDB. But more commonly, teams implement a manual sharding strategy, which can present challenges when Accounts are colocated:
- Maintaining double-entry atomicity across shards
- Aggregating balances and Entries efficiently across distributed Accounts
- Coordinating background processes (e.g., balance drift checks)
Solution: Assurances Within Sharding Layers
If you build your own layer, focus on ensuring:
- Strong consistency guarantees across Accounts
- Cross-shard Transaction management
- Metrics collection and alerting per shard
Conclusion: Engineering for Growth
We hope this series offered a clear overview of the importance of solid ledgering infrastructure that:
- Preserves financial correctness
- Scales to meet product demand
- Supports internal systems and end-user use cases
We also can't overstate the amount of investment it takes to get right. If you want a production-ready ledger that meets these requirements today and can scale with you, check out Modern Treasury Ledgers and reach out to us.
Get the latest articles, guides, and insights delivered to your inbox.




