Build Logs24 January 20267 min read

Double-Entry Ledgers in PostgreSQL: Building FinOps Systems Without ORM Magic

Why financial ledgers deserve raw SQL and explicit transactions instead of ORM abstractions, and how to enforce double-entry balance in Postgres.

BarathProduct Engineer
FIGURE 1.0
ORIGINAL ARCHITECTURE
Double-entry accounting ledger architecture render (Dima FinOps)
CONTEXT:Double-entry accounting ledger architecture render (Dima FinOps)
ON THIS PAGE (10 SECTIONS)
EXECUTIVE BRIEFING · KEY TAKEAWAYS
  • ✓Model money as immutable double-entry rows — a transaction header plus balanced debit and credit entries — never as a single mutable balance column.
  • ✓Enforce the zero-sum rule with a database constraint trigger, not application code, so no code path can ever write an unbalanced transaction.
  • ✓Use SERIALIZABLE isolation with an explicit retry loop for concurrent postings; ORMs often hide exactly the failure you need to see and retry on.

Any system that tracks money — ad spend budgets, marketplace payouts, internal fund pools — eventually needs a ledger, and the standard ORM workflow of load an object, mutate a balance field, save it back is exactly the pattern that produces silent financial corruption under concurrency. The fix is to model money as immutable double-entry rows, enforce the balance rule inside the database itself, and keep the write path in raw SQL where every lock and every isolation guarantee is visible directly in the code that uses it.

Double-entry bookkeeping's core rule is simple: every transaction is recorded as a set of debits and credits whose amounts sum to zero. A single mutable "balance" column can't enforce that rule on its own, and it's exactly the kind of column two concurrent transactions can both read, both increment based on a now-stale value, and both write back — a classic lost update, and in a ledger, a lost update means money that doesn't reconcile.

Why ORMs Are the Wrong Tool for This One Job

This isn't an anti-ORM argument in general — plenty of read paths and simpler write paths are fine behind one. It's specific to the ledger write path, where three ORM behaviors become genuinely dangerous: implicit autocommit on individual statements when you assumed you were inside one transaction, a connection pool that silently hands a different underlying connection to a later call in the same "unit of work," and lazy-loaded relations that issue an extra query mid-transaction that you never explicitly wrote. Any one of these can quietly break the atomicity you were relying on, and none of them show up as an obvious error.

Schema: Transactions, Entries, and the Zero-Sum Constraint

The schema splits into a transaction header and a set of entry rows, one per debit or credit leg, linked by a foreign key. Amounts are stored as integer cents, never as floating point, to avoid rounding drift.

sql
1CREATE TABLE accounts (
2 id TEXT PRIMARY KEY,
3 name TEXT NOT NULL,
4 type TEXT NOT NULL CHECK (type IN ('asset', 'liability', 'equity'))
5);
6 
7CREATE TABLE transactions (
8 id TEXT PRIMARY KEY,
9 idempotency_key TEXT UNIQUE NOT NULL,
10 description TEXT NOT NULL,
11 posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12);
13 
14CREATE TABLE entries (
15 id BIGSERIAL PRIMARY KEY,
16 transaction_id TEXT NOT NULL REFERENCES transactions(id),
17 account_id TEXT NOT NULL REFERENCES accounts(id),
18 amount_cents BIGINT NOT NULL
19);

A plain `CHECK` constraint can't enforce the zero-sum rule because it can only see one row at a time, and the balance check needs to sum across every entry for a transaction. A deferred constraint trigger solves this: it runs after all the entries for a transaction have been inserted, right before commit, and rolls back the whole transaction if the sum isn't zero.

sql
1CREATE FUNCTION check_transaction_balance() RETURNS TRIGGER AS $
2DECLARE
3 total BIGINT;
4BEGIN
5 SELECT SUM(amount_cents) INTO total
6 FROM entries
7 WHERE transaction_id = NEW.transaction_id;
8 
9 IF total <> 0 THEN
10 RAISE EXCEPTION 'Transaction % is unbalanced: sum = %', NEW.transaction_id, total;
11 END IF;
12 
13 RETURN NULL;
14END;
15$ LANGUAGE plpgsql;
16 
17CREATE CONSTRAINT TRIGGER entries_balance_check
18 AFTER INSERT ON entries
19 DEFERRABLE INITIALLY DEFERRED
20 FOR EACH ROW
21 EXECUTE FUNCTION check_transaction_balance();

Posting a Transaction With SERIALIZABLE Isolation

Two transactions posting against the same account at nearly the same time can each read a consistent snapshot, compute a result that looks valid in isolation, and still produce an outcome that's wrong once both commit — a write skew anomaly that weaker isolation levels don't prevent. `SERIALIZABLE` isolation does prevent it, at the cost of occasionally aborting one of the two transactions with a serialization failure that the caller is expected to retry.

javascript
1async function postTransaction(pool, { id, idempotencyKey, description, entries }) {
2 const client = await pool.connect();
3 const maxRetries = 5;
4 
5 for (let attempt = 0; attempt < maxRetries; attempt++) {
6 try {
7 await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
8 
9 await client.query(
10 'INSERT INTO transactions (id, idempotency_key, description) VALUES ($1, $2, $3) ON CONFLICT (idempotency_key) DO NOTHING',
11 [id, idempotencyKey, description]
12 );
13 
14 for (const entry of entries) {
15 await client.query(
16 'INSERT INTO entries (transaction_id, account_id, amount_cents) VALUES ($1, $2, $3)',
17 [id, entry.accountId, entry.amountCents]
18 );
19 }
20 
21 await client.query('COMMIT');
22 return;
23 } catch (err) {
24 await client.query('ROLLBACK');
25 if (err.code === '40001' && attempt < maxRetries - 1) {
26 await sleep(50 * 2 ** attempt + Math.random() * 50);
27 continue;
28 }
29 throw err;
30 }
31 }
32}

Preventing Overdrafts Without Trusting the Caller

Application-level balance checks are only as good as every code path that's supposed to call them. A second trigger that recomputes an account's running balance from its entries and rejects the insert if an asset account would go negative closes that gap at the only layer that can't be bypassed by a bug somewhere else in the codebase.

sql
1CREATE FUNCTION check_asset_balance() RETURNS TRIGGER AS $
2DECLARE
3 running_balance BIGINT;
4BEGIN
5 SELECT COALESCE(SUM(e.amount_cents), 0) INTO running_balance
6 FROM entries e
7 JOIN accounts a ON a.id = e.account_id
8 WHERE e.account_id = NEW.account_id AND a.type = 'asset';
9 
10 IF running_balance < 0 THEN
11 RAISE EXCEPTION 'Account % would go negative: %', NEW.account_id, running_balance;
12 END IF;
13 
14 RETURN NULL;
15END;
16$ LANGUAGE plpgsql;

Idempotent Postings and Reading Balances Efficiently

The `idempotency_key` column on `transactions`, combined with `ON CONFLICT DO NOTHING`, makes the whole "post this transaction" call safe to retry from the caller's side after a network timeout — if the first attempt actually succeeded, the retry is a harmless no-op instead of a duplicate posting. On the read side, recomputing an account's balance with `SUM()` over every entry works fine at moderate volume; once that sum gets expensive, a running-balance table updated transactionally alongside each entry insert avoids the full recompute without giving up the entry-level audit trail underneath it.

Wrong vs Right: Editing a Mistake vs Reversing It

Once a transaction has posted, the instinct when you spot an error is to fix it in place — update the amount, delete the bad entries, correct the record. That instinct is exactly what a ledger schema is designed to refuse. Editing or deleting posted entries destroys the audit trail that makes a ledger trustworthy in the first place: there's no longer a record of what actually happened, only what you've decided should have happened. The correct fix is to post a second, reversing transaction that cancels out the first one's effect, leaving both the mistake and the correction permanently visible.

sql
1-- Wrong: mutating a posted entry destroys the audit trail
2-- UPDATE entries SET amount_cents = 120000 WHERE transaction_id = 'tx-88329';
3 
4-- Right: post a reversing transaction referencing the original
5INSERT INTO transactions (id, idempotency_key, description, reverses_transaction_id)
6VALUES ('tx-88330', 'reverse-tx-88329', 'Correction for tx-88329', 'tx-88329');
7 
8INSERT INTO entries (transaction_id, account_id, amount_cents) VALUES
9 ('tx-88330', 'acc_cash_assets', -150000),
10 ('tx-88330', 'acc_accounts_rec', 150000);

Multi-Currency Transactions Need Their Own Legs, Not Silent Conversion

An account holds one currency. When a transaction genuinely crosses currencies — paying out in dollars against a rupee-denominated fund pool, for example — model the conversion as an explicit pair of entries against an FX clearing account, with the exchange rate used recorded on the transaction itself. Converting the amount silently inside the posting function and writing a single cross-currency entry throws away the information you'll need later to explain why the books don't match today's spot rate.

Operational Caveats: Growth, Backups, and Migrations

A ledger table is append-only by design, which means it never shrinks and eventually becomes the largest table in the system. Plan for that early: partition `entries` by month or quarter so old data can be archived or queried without scanning everything, and make sure your backup and point-in-time-recovery strategy is tested against a table this shape, not just assumed to work because it works for smaller tables. The other operational rule worth stating plainly: never run a destructive schema migration against `transactions` or `entries` without a rollback plan that itself respects the ledger's own rules — if a migration needs to "undo" something, it should do so with a reversing transaction, not a `DELETE`.

What This Looks Like at Vasiva

Dima's fund pools and budget tracking are built on this same shape — ledger-based tracking rather than a single mutable balance column — because the deterministic budget projections the product surfaces are only trustworthy if the numbers underneath them are calculated from an immutable, balanced set of entries, not guessed at by anything, including a model.

If you're building anything where the numbers have to reconcile — internal ledgers, fund tracking, budget systems — that's Product Engineering and Internal Tools territory for us. Get in touch if you want your schema reviewed before it's carrying real transactions.

ACADEMIC & INDUSTRY CITATIONS

References & Prior Art

REF 01Domain Pattern

Accounting Patterns: Accounting Transaction & Double-Entry Ledger

Martin Fowler • martinfowler.com (2015)

Source
REF 02Database Engine

PostgreSQL Documentation: Chapter 13. Concurrency Control & Serializable Snapshot Isolation (SSI)

PostgreSQL Core Team • PostgreSQL Global Development Group (2025)

Source
REF 03Foundational Paper

A Critique of ANSI SQL Isolation Levels

Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O’Neil, Patrick O’Neil • ACM SIGMOD Record 24(2) (1995)

Source
VERIFIED CONTRIBUTOR

Barath

Product Engineer

Barath works across Vasiva's finance-adjacent engineering — double-entry ledger schemas, reconciliation logic, and explicit transaction boundaries.

CURATED ARCHIVE

Continue Reading

Contextually relevant engineering logs and production architecture notes.

SYSTEM ADVISORY

Have an AI workflow or complex system worth exploring?

Work directly with the senior engineers who designed and shipped these systems. Zero vendor lock-in, deterministic architectures, and first-party audit integrity.

Primary Domain:internal tools
Evaluate the opportunity