Double-Entry Ledger Design for Software Engineers
When building financial systems, billing engines, or wallet platforms, the standard "single column balance" approach (e.g., updating a balance column in a users table) is a recipe for disaster. It fails audits, makes reconciliation impossible, and cannot survive concurrency failures.
To build a system that is secure, auditable, and resilient to failures, you must implement a Double-Entry Ledger.
The Core Principles of Double-Entry
In double-entry bookkeeping, money never appears or disappears; it is only transferred from one account to another. Every transaction consists of a set of entries where:
$$\text{Total Debits} = \text{Total Credits}$$
An account type determines whether a debit increases or decreases its value:
- Assets / Expenses: Increased by debits, decreased by credits.
- Liabilities / Equity / Revenue: Increased by credits, decreased by debits.
Relational Database Schema Design
A robust ledger database requires three primary tables:
- Accounts: The containers holding balances of specific types.
- Transactions: The events representing a business operation.
- Entries: The individual credit or debit allocations mapped to accounts.
Here is the PostgreSQL schema:
CREATE TABLE accounts (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
currency VARCHAR(3) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transactions (
id UUID PRIMARY KEY,
description TEXT NOT NULL,
metadata JSONB,
posted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE entries (
id UUID PRIMARY KEY,
transaction_id UUID REFERENCES transactions(id) ON DELETE CASCADE,
account_id UUID REFERENCES accounts(id),
amount NUMERIC(18, 4) NOT NULL, -- Positive for debits/credits depending on context
direction VARCHAR(2) NOT NULL CHECK (direction IN ('DB', 'CR')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_entries_account ON entries(account_id);
CREATE INDEX idx_entries_transaction ON entries(transaction_id);
Implementing Verification Logic in Python
To guarantee the integrity of your ledger, your application must verify that all transactions balance before persisting them. Here is a Python implementation utilizing SQL transactions and sanity checks:
import uuid
from decimal import Decimal
from typing import List, Dict
class LedgerError(Exception):
pass
class LedgerEntry:
def __init__(self, account_id: str, amount: Decimal, direction: str):
self.account_id = account_id
self.amount = amount
self.direction = direction
def verify_and_save_transaction(db_connection, description: str, entries: List[LedgerEntry]):
# 1. Calculate and verify zero-sum balance
total_debits = sum(e.amount for e in entries if e.direction == 'DB')
total_credits = sum(e.amount for e in entries if e.direction == 'CR')
if total_debits != total_credits:
raise LedgerError(f"Transaction does not balance. Debits: {total_debits}, Credits: {total_credits}")
tx_id = str(uuid.uuid4())
with db_connection.cursor() as cursor:
# 2. Insert transaction
cursor.execute(
"INSERT INTO transactions (id, description) VALUES (%s, %s)",
(tx_id, description)
)
# 3. Insert matching entries
for entry in entries:
cursor.execute(
"""
INSERT INTO entries (id, transaction_id, account_id, amount, direction)
VALUES (%s, %s, %s, %s, %s)
""",
(str(uuid.uuid4()), tx_id, entry.account_id, entry.amount, entry.direction)
)
print(f"Successfully posted transaction {tx_id} to database.")
Key Takeaways
-
Immutability: Never use SQL
UPDATEon ledger entries. If a mistake is made, post a reversing transaction. -
Strong Typing: Use arbitrary-precision numeric types (
NUMERICorDECIMAL) for monetary values—never float. - Zero-Sum Guarantees: Balance checks should be enforced both at the application tier and via database triggers or constraints.
Top comments (1)
I particularly appreciated the emphasis on the core principles of double-entry bookkeeping, where every transaction is represented by a set of entries with total debits equaling total credits. The PostgreSQL schema design you provided is robust and well-structured, with the use of separate tables for accounts, transactions, and entries allowing for efficient querying and auditing. One potential improvement could be to consider adding a column to the
entriestable to store the running balance for each account after each transaction, which would facilitate reconciliation and debugging. Have you considered implementing any additional validation or sanity checks in theverify_and_save_transactionfunction to handle edge cases, such as duplicate transactions or concurrent modifications to the same account?