DEV Community

DEV-AI
DEV-AI

Posted on

Handling Balances and Transactions: Patterns and Anti-Patterns

Almost every business system has to answer one question correctly: how much is in this account right now? The account might be a ledger account, a customer receivable, a wallet or a loyalty-point balance. The obvious design is a balance column that every transaction updates. It works in a demo. Under real load, audit requirements and integrations that retry, it tends to produce lost updates, unexplained differences and slow reports.
This article describes a different approach, taken from how Odoo 19 handles accounting on PostgreSQL. Odoo does not treat a mutable "current balance" as the authoritative record. It stores balanced journal lines and the matching events between them. It keeps a few line-level values that are read constantly, such as the open residual. It calculates every broader balance with set-based queries.

Core rule: write immutable ledger facts and read balances as indexed projections. Keep a stored balance only as a cache you can rebuild.[1]

1. Foundation: Ledger Model

Why: A single mutable balance row becomes a contention hotspot. It also can't answer historical questions such as "balance on 30 June" or "balance by partner".[1]
Benefit: You get a full audit trail, and any filter (account, date, partner, currency) can produce a balance.
Impact: Writes become cheap inserts, and reads become aggregates that indexes keep fast.

CREATE TABLE ledger_entry (
  id bigserial PRIMARY KEY,
  company_id bigint NOT NULL,
  external_system text,
  external_event_id text,
  state text NOT NULL DEFAULT 'draft'
);

CREATE TABLE ledger_line (
  id bigserial PRIMARY KEY,
  entry_id bigint NOT NULL REFERENCES ledger_entry(id),
  account_id bigint NOT NULL,
  partner_id bigint,
  posted_at date NOT NULL,
  amount numeric(18,2) NOT NULL,          -- signed: debit +, credit -
  amount_residual numeric(18,2) NOT NULL DEFAULT 0,
  reconciled boolean NOT NULL DEFAULT false,
  state text NOT NULL DEFAULT 'draft'
);
Enter fullscreen mode Exit fullscreen mode

Example: A 500.00 sale inserts two rows, receivable +500 and revenue -500. No balance column is updated.

Choose one canonical amount. If debit, credit and balance are maintained independently, they can drift. Store the signed amount and derive the others.[1]

SELECT GREATEST(amount,0) AS debit, GREATEST(-amount,0) AS credit FROM ledger_line;
Enter fullscreen mode Exit fullscreen mode

2. Write Patterns

Atomic, balanced posting

Why: An entry spans several lines, and a partial write leaves the books wrong.
Benefit: Other sessions never see half-finished work, and a failure rolls everything back.
Impact: The ledger stays consistent.[1]

BEGIN;
INSERT INTO ledger_line(entry_id, account_id, posted_at, amount)
VALUES (1001, 10, CURRENT_DATE, 500.00), (1001, 20, CURRENT_DATE, -500.00);

SELECT entry_id FROM ledger_line
WHERE entry_id = 1001
GROUP BY entry_id
HAVING ROUND(SUM(amount),2) <> 0;   -- row returned means ROLLBACK
COMMIT;
Enter fullscreen mode Exit fullscreen mode

When validating many entries, use WHERE entry_id = ANY(...) in one grouped query. Don't load lines into the application.[1]

Database constraints for local rules

Why: Application checks can be bypassed by scripts or other services.
Benefit: Invalid rows can't exist.
Impact: Fewer integrity incidents. Rules spanning rows, such as entry balance, still need the grouped check above.[1]

ALTER TABLE ledger_line ADD CONSTRAINT chk_acc CHECK (account_id IS NOT NULL);
Enter fullscreen mode Exit fullscreen mode

Idempotent posting

Why: Integrations retry, and "search then insert" races between two workers.
Benefit: The database decides the winner.
Impact: No duplicates after timeouts or message replays.[1]

ALTER TABLE ledger_entry
  ADD CONSTRAINT uq_event UNIQUE (company_id, external_system, external_event_id);

INSERT INTO ledger_entry(company_id, external_system, external_event_id)
VALUES (1,'ERP','INV-778')
ON CONFLICT (company_id, external_system, external_event_id) DO NOTHING
RETURNING id;   -- no row means already posted
Enter fullscreen mode Exit fullscreen mode

Reverse and repost

Why: Editing posted history breaks audit trails and period closing.
Benefit: Every correction is visible.
Impact: The ledger stays append-only.[1]

INSERT INTO ledger_line(entry_id, account_id, posted_at, amount, state)
SELECT %(new_entry)s, account_id, CURRENT_DATE, -amount, 'posted'
FROM ledger_line WHERE entry_id = %(old_entry)s;
Enter fullscreen mode Exit fullscreen mode

Batch writes

Why: Per-record loops repeat ORM, access-control, recompute and query overhead.
Benefit: Overhead is paid once per batch.
Impact: Much faster imports and integrations.[1]

Line.create(vals_list)                       # good
lines.write({'custom_state': 'processed'})   # good
for v in vals_list: Line.create(v)           # avoid
Enter fullscreen mode Exit fullscreen mode

Atomic counters, only when unavoidable

Why: Wallet and quota use cases sometimes need a mutable balance.
Benefit: The check and the update happen in one statement.
Impact: There is no lost update, but you lose history unless you also append a ledger row.[1]

UPDATE wallet_account SET balance = balance - %(amt)s
WHERE id = %(id)s AND balance >= %(amt)s
RETURNING balance;   -- no row means insufficient funds or no account
Enter fullscreen mode Exit fullscreen mode

3. Read Patterns

Grouped balance

Why: One query per account (N+1) grows with the number of accounts.
Benefit: The query count stays roughly constant.
Impact: Dashboards and reports stay fast as data grows.[1]

SELECT account_id,
       SUM(amount) FILTER (WHERE amount>0)  AS debit,
       -SUM(amount) FILTER (WHERE amount<0) AS credit,
       SUM(amount) AS balance
FROM ledger_line
WHERE account_id = ANY(%(ids)s) AND state='posted'
  AND posted_at BETWEEN %(from)s AND %(to)s
GROUP BY account_id;
Enter fullscreen mode Exit fullscreen mode

Accounts with no rows are absent from the result, so default them to zero in the caller.

Running balance with a window

Why: A running balance depends on the filter and order, so it can't be a global stored value.
Benefit: It is correct for any filter and costs one query.
Impact: Statements are accurate, provided the window runs before pagination.[1]

WITH opening AS (
  SELECT COALESCE(SUM(amount),0) AS amt FROM ledger_line
  WHERE account_id=%(acc)s AND state='posted' AND posted_at < %(from)s
), period AS (
  SELECT id, posted_at, amount,
         SUM(amount) OVER (ORDER BY posted_at, id
              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS run
  FROM ledger_line
  WHERE account_id=%(acc)s AND state='posted'
    AND posted_at BETWEEN %(from)s AND %(to)s
)
SELECT p.*, o.amt + p.run AS running_balance
FROM period p CROSS JOIN opening o ORDER BY posted_at, id;
Enter fullscreen mode Exit fullscreen mode

The explicit ROWS frame avoids default peer-row behavior, and id makes the order deterministic.

Open items and partner balance

Why: Payments can be partial, multiple or in foreign currency, and overwriting the invoice destroys history.
Benefit: The original fact stays intact, matching is reversible, and aging is cheap.
Impact: Unreconciling means deleting a match row and recomputing the residual.[1]

CREATE TABLE ledger_match (
  id bigserial PRIMARY KEY,
  debit_line_id bigint REFERENCES ledger_line(id),
  credit_line_id bigint REFERENCES ledger_line(id),
  amount numeric(18,2) NOT NULL
);

SELECT partner_id, SUM(amount_residual) AS due
FROM ledger_line
WHERE reconciled IS NOT TRUE AND state='posted'
GROUP BY partner_id;
Enter fullscreen mode Exit fullscreen mode

The residual is the line amount, minus what is matched as debit, plus what is matched as credit. Recompute it in batches. Example: an invoice of 1000 with payments of 300 and 200 leaves a residual of 500 and reconciled = false.

Indexes that match the queries

Why: Balance queries filter by account and date, and by open status.
Benefit: Scans touch only the needed rows, and a partial index stays small.
Impact: Reads get faster, but every index slows writes, so add only the dominant ones.[1]

CREATE INDEX idx_acct_date ON ledger_line(account_id, posted_at);
CREATE INDEX idx_open ON ledger_line(account_id, partner_id) WHERE reconciled IS NOT TRUE;
Enter fullscreen mode Exit fullscreen mode

Use the same predicate in the query (reconciled IS NOT TRUE), or the planner may skip the partial index.

Rebuildable snapshots

Why: Some fixed aggregates, such as daily balances, are too costly to recompute repeatedly.
Benefit: Hot reports read quickly.
Impact: Backdated entries, reversals and migrations must update or rebuild the affected dates. Add a snapshot only after profiling.[1]

CREATE TABLE account_daily_balance (
  account_id bigint, balance_date date, balance numeric NOT NULL DEFAULT 0,
  PRIMARY KEY (account_id, balance_date));

INSERT INTO account_daily_balance VALUES (%s,%s,%s)
ON CONFLICT (account_id, balance_date)
DO UPDATE SET balance = account_daily_balance.balance + EXCLUDED.balance;
Enter fullscreen mode Exit fullscreen mode

For analytics, use a materialized view refreshed asynchronously, not on every post.[1]

4. Anti-Patterns

Anti-pattern Why it hurts Fix
Read, add, write (bal = bal + x in app code) Concurrent workers lose updates, which causes silent money errors Atomic SQL update, or append a ledger row
Stored balance as the only truth Hot row, no history, no dimensional queries Ledger plus aggregates
Query per account or partner The query count grows with the data One grouped query
Summing rows in Python Network and memory cost SUM ... GROUP BY in the database
Running total over the current page The total restarts on page 2 Window over the full set, then paginate
Editing posted lines Breaks audit and closed periods Reverse and repost
Float money (0.1+0.2 != 0.3) Rounding errors numeric, currency rounding, precision-aware zero test
Search, then insert Race creates duplicates Unique constraint
Stored partner total over all lines Broad recomputation on every post Sum open residuals
Partial-index predicate mismatch Index not used Keep predicates identical
Materialized-view refresh per post A cheap insert becomes a global refresh Scheduled refresh
Raw SQL without flush or invalidate Stale reads and ORM cache, bypassed rules Flush before reads, invalidate after writes
Commits inside loops Breaks atomicity and rollback One transaction per business operation
Indexing every dimension Slower writes, more storage Index only dominant predicates

All rows reflect the anti-pattern list in the source document.[1]

Concurrency example. Two workers each read a balance of 100 and add 50. The stored result is 150, not 200. The atomic statement SET balance = balance + 50 avoids this because PostgreSQL serializes the row update.

5. Production Checklist

  • Each entry's signed total rounds to zero.
  • Posting is batched, atomic and idempotent.
  • Posted and locked-period rows are protected.
  • Multi-currency and partial-reconciliation rounding are tested.
  • EXPLAIN (ANALYZE, BUFFERS) runs on production-shaped data.
  • Account/date and open-item queries use the intended indexes.
  • Concurrent posting and reconciliation are load-tested.
  • Snapshots can be rebuilt from the ledger.

The SQL is simplified and untested against your schema. The table definitions, reversal insert and idempotent insert are my own illustrative examples.

Top comments (0)