8 minute read

I think ledgers are underutilized in software development today. Specifically, double-entry ledger modeling would be a better fit in a lot of systems than the ad-hoc ledger-ish things they currently have.

This is why I’ve been working on pgledger, a pure PostgreSQL ledger implementation. If adding a ledger implementation is super simple, then I’m hoping more folks will do it. And it can become another modeling primitive that we reach for to accomplish all sorts of things.

What is a Ledger?

A double-entry ledger at its core is a few simple concepts put together:

  • The current amount or balance of a thing
  • A historical record of how the amount got to that amount (immutable, append only, etc)
  • Where that amount came from at each step

That’s it. So if Alice sends $100 to Bob, the ledger records Alice’s balance changing from $0 to $-100 (going to Bob) and Bob’s balance changing from $0 to $100 (coming from Alice). All of this is recorded at once, all amounts are accounted for, and all balances sum to $0.

Note: Many ledgers model debits and credits rather than negative and positive numbers. In this case, Alice would have a debit of $100 and Bob would have a credit of $100. Personally, I find using negative and positive numbers simpler.1

The fact that every transfer only moves amounts, never creates them from scratch, is a built-in error check.2 And the historical record serves as an audit log.

How the ledger is implemented and what is actually stored on disk varies with each ledger implementation, but the important point is that all of this information is recorded atomically.

Once you start thinking about tracking amount changes over time, you start seeing ledgers in more places. Let’s walk through some examples I’ve seen in real software.

Recording Payments

Say we are building an online business. Starting simple, we need to know when someone places an order, so maybe we start with an orders table. But then we realize that payments have a more complicated lifecycle (we don’t receive the payment right away when an order is created), so we want to know when we can actually start service or ship a product. We might add a payments table with a status column that represents values like waiting_to_receive or complete.

This is sort of like a single entry ledger. We have a table of “transfers” from customers to our business. But things soon get more complicated. How do we record a refund? Is that a new refunds table? Or do we record a row in payments with a negative amount? What happens when our account balance isn’t what we expect? Are we missing payments? Or did we receive a different amount than we expected? How can we figure it out?

If we have a real double-entry ledger, we can record these interactions more explicitly:

When an order is created, we now have a receivable, where we are waiting on money. We can represent this as a transfer from the external user to a receivables account:

Transfer ID Description user receivables available
1 order created -$10 $10  

Note that each row in this representation is a transfer, and each column to the right of the vertical bar (┃) is an account. All of the row amounts sum to $0.

Then, when we actually receive the money in our account, we can move it from the receivables to our available balance:

Transfer ID Description user receivables available
1 order created -$10 $10  
2 payment received   -$10 $10

And now we can see where the built in error checking comes into play. After receiving the payment, the receivables balance should be $0. If it isn’t, something went wrong, such as receiving less than we expected. With the original modeling, we’d have to build something custom to check the received amount against what we expected. We can also easily answer questions like “how much money are we waiting for?” without any extra logic (the balance of the receivables account).

Or if we don’t have the balance we expect, it’s easier to figure out why. We can look at the entries for an account and see every balance change over time and look for discrepancies. We can also look at other balances and see how they relate. Maybe our bank balance is $100 lower than we expect, but a different account is $100 more than we expect. In that case, we can look for missing or incorrect transfers between those two accounts.

Continuing the modeling, refunds would go the other way, often for a different amount (e.g. partial refund):

Transfer ID Description user receivables available
1 order created -$10 $10  
2 payment received   -$10 $10
3 partial refund $5   -$5

Now we can see the external user received $5 back, and the company’s available fund only has $5 in it now. We have a unified view over both payments and refunds in the same tables. And we can see where the money went at every step.

This is obviously a simplified example, but another benefit of maintaining a ledger is the ability to add as many accounts as we want. For example, instead of maintaining a single receivables account, we can have a receivables account per user. Or we can have sub accounts within the available account to manage pending funds, held funds, or more.3

Reward Points

Tracking payments is perhaps an obvious example, so let’s consider a different case for moving around amounts: tracking user points. For example, a user can earn points by taking actions on our site, such as posting a message or referring a friend. Or maybe they earn points based on purchases, like airline miles.

If we were going to start super simple, maybe we’d just add a points column to the users table. Then, when someone earns or spends points, we just update the amount:

update users SET points = points + 100 where id = 'u_123';

But then we learn we need to show someone a history of their point changes. So next, we introduce a point_events table to add an audit log of point changes. We write a new row whenever points are earned or spent. But already we can start to see the complexity growing. Now, we need to atomically write a row and update a balance at the same time, and we need to ensure that concurrent actions don’t conflict with each other.

Over time, the requirements keep growing and getting more complicated:

  1. Once points are spent or used, there will be a row in the point_events table with a negative amount. But where did the points go? Were they sent to another user? Were they spent? Did they expire? How do we track this? Do we add new columns to track this data?
  2. How do we model users sending points to another user? Presumably we record two rows to the point_events table and update two balances, but we have to ensure our code writes everything atomically and correctly.
  3. What links these two rows together? Do we need optional foreign keys on the point_events table, populating when it’s a transfer between users?

As the features evolve, the requirements look more and more like a double-entry ledger, with the “currency” of each account set to “points”. Rather than build an ad-hoc bespoke data model that we need to keep expanding, we can use ledger modeling from the beginning which handles all of these cases.

Let’s start with a points account per user, with transfers coming from a single company account. In reality, you would probably use different company accounts for different purposes or types of points. We can also use a spent account to track when points are spent.

Transfer ID description company user1 user2 spent
1 user1 earns 100 points -100 100    
2 user2 earns 200 points -200   200  
3 user2 spends 100 points     -100 100
4 user1 sends user2 50 points   -50 50  

At the end of this flow, it’s easy to see that user1 has 50 points, user2 has 150, the company has sent 300 points, and users have spent 100 of those.

The ledger also gives us simple auditability. If user2 wants to know why their balance is 150, we can show them the series of ledger entries that result in that balance (along with the counterparty, timestamp, etc).

Later, we can even model redeeming points for cash/gift cards as a currency conversion from “points” to “USD”, capturing the exchange rate in the ledger as well without any extra modeling.4

More Use Cases

Another similar use case is modeling usage credits for an API, such as buying credits, spending them on various actions, and monitoring when they approach or reach zero. Credits would be another “currency” and the various things to track would each be accounts.

Taking it further, we can model things like content moderation actions per user (e.g. offenses, warnings, appeals, etc). Each user has accounts for the various actions, so we can count them over time, understand totals, compute reputation scores, etc.

A ledger could even represent an inventory management system, tracking quantities of items in various locations, movement between them, and their current states.

Summary

The main idea here is that if an app already has ledger modeling built in, then many things can be built on top of it without a lot of extra work or complexity per use case. We don’t need to reinvent concepts and modeling and code each time. We just use the ledger with a new set of accounts and currencies. There’s an initial cost to introducing a ledger, but then that value is recouped over time.

And the ledger components can be encapsulated with clear seams and interfaces. The ledger implementation stands alone, and the business logic is how you structure the accounts and transfers.

How you add ledgers as a core component is up to you. You can use pgledger, TigerBeetle, your own custom code, or something else entirely. And if you find more interesting use cases for ledgers, please let me know!

  1. I learned a lot about double-entry accounting from the Ledger CLI tool, which also uses negative and positive numbers: https://ledger-cli.org/doc/ledger3.html#Stating-where-money-goes 

  2. More specifically, the full accounting equation is generally written as Assets = Liabilities + Equity 

  3. For a longer discussion, see https://github.com/pgr0ss/pgledger/discussions/29 

  4. For examples on currency conversions, see https://github.com/pgr0ss/pgledger?tab=readme-ov-file#currencies or https://docs.tigerbeetle.com/coding/recipes/currency-exchange/ 

Updated: