Ledger Database Design for Fintech: 2027 Reference
The schema decisions you make in week one will haunt your reconciliation engineers for years. Here's how to get the ledger right from the start.

Financial software fails at the database layer more often than it fails at the business logic layer. A misplaced ON DELETE CASCADE, a mutable balance column, or a missing constraint on debits-equal-credits will cost you weeks of forensic accounting — and, eventually, a regulator's attention. This reference is for engineers building or rebuilding a fintech ledger in 2027: a period when regulators expect machine-readable audit trails and investors expect sub-second balance queries at scale.
What follows is opinionated and specific. We draw on production patterns from core-banking migrations, the CFPB's 2026 guidance on data integrity for consumer payment platforms, and the Federal Reserve's FedNow interoperability technical specifications. The goal is a ledger database design that survives Series B transaction volumes without a rewrite.
Why Append-Only Is Non-Negotiable
The accounting profession codified immutability centuries before the relational database existed. Double-entry bookkeeping, formalized by Luca Pacioli in 1494, treats every record as permanent and every correction as a new, offsetting record. A ledger is not a spreadsheet; rows are not edited, they are superseded.
In database terms, this means:
- No
UPDATEstatements on posted transaction rows — ever. - No
DELETEstatements without a corresponding reversal entry. - A
statuscolumn is acceptable for in-flight states (pending,posted,voided), butvoidedmust be implemented as a new row referencing the original, not an overwrite.
The enforcement mechanism is a combination of PostgreSQL row-level security, application-level write paths, and — for the paranoid (correctly paranoid) — a trigger that raises an exception on any UPDATE or DELETE touching the transactions table after status = 'posted'.
Audit reality check: The CFPB's 2024 supervisory highlights cited mutable ledger records as a contributing factor in three enforcement actions against payment processors. By 2026, several state banking regulators had begun explicitly requiring append-only audit logs as part of money-transmitter license renewals. See the CFPB supervisory highlights archive for primary documentation.
The Core Schema: Four Tables, One Invariant
A production-grade immutable ledger can be expressed in four tables. Everything else is a projection.
accounts
CREATE TABLE accounts (
account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_ref TEXT UNIQUE NOT NULL,
currency CHAR(3) NOT NULL,
type TEXT NOT NULL CHECK (type IN ('asset','liability','equity','revenue','expense')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ
);
Notice there is no balance column. Balance is always computed — or read from a materialized view. Storing a running balance in the accounts table creates a second source of truth and, inevitably, drift.
transactions
CREATE TABLE transactions (
txn_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
posted_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','posted','voided')),
description TEXT,
metadata JSONB
);
entries
CREATE TABLE entries (
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
txn_id UUID NOT NULL REFERENCES transactions(txn_id),
account_id UUID NOT NULL REFERENCES accounts(account_id),
amount NUMERIC(20,8) NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('debit','credit')),
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
balances (materialized)
CREATE MATERIALIZED VIEW account_balances AS
SELECT
e.account_id,
a.type,
SUM(CASE WHEN e.direction = 'debit' THEN e.amount ELSE 0 END) AS total_debits,
SUM(CASE WHEN e.direction = 'credit' THEN e.amount ELSE 0 END) AS total_credits
FROM entries e
JOIN transactions t ON t.txn_id = e.txn_id
JOIN accounts a ON a.account_id = e.account_id
WHERE t.status = 'posted'
GROUP BY e.account_id, a.type;
Refresh this view on a schedule (every 15 seconds works for most mid-size platforms) and index on account_id. For real-time requirements, maintain a balance_snapshots table updated by a trigger on entry insert — but treat snapshots as a cache, never as the ledger of record.
Enforcing Double-Entry at the Database Layer
Business logic enforcing double entry is necessary but not sufficient. Application bugs, migration scripts, and direct database access by engineers will eventually bypass it. The constraint must live in the database.
The cleanest approach is a deferred constraint trigger that fires at transaction commit:
CREATE OR REPLACE FUNCTION check_double_entry()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
net NUMERIC;
BEGIN
SELECT
SUM(CASE WHEN direction = 'debit' THEN amount
WHEN direction = 'credit' THEN -amount END)
INTO net
FROM entries
WHERE txn_id = NEW.txn_id;
IF net <> 0 THEN
RAISE EXCEPTION
'Double-entry violated for txn_id %: net = %',
NEW.txn_id, net;
END IF;
RETURN NEW;
END;
$$;
CREATE CONSTRAINT TRIGGER enforce_double_entry
AFTER INSERT ON entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_double_entry();
Using DEFERRABLE INITIALLY DEFERRED is critical: it lets you insert both the debit and credit rows within a single transaction before the constraint fires, which is how every real payment works.
Common mistakes that break this pattern:
- Inserting entries across two separate transactions — the constraint fires before the offsetting entry exists.
- Using
NUMERIC(19,4)and then handling foreign-currency amounts that require 8 decimal places (useNUMERIC(20,8)as a floor). - Forgetting that
SUMover an empty set returnsNULL, not0— handle withCOALESCE. - Allowing entries with mismatched
currencyon the same transaction without an explicit FX conversion entry — treat every currency as a separate subledger. - Skipping the
idempotency_keyon transactions — without it, network retries create duplicate postings.
Indexing Strategy for Audit and Reconciliation
A ledger that answers "what was this account's balance at 11:58:03 UTC on March 15, 2027?" is an audit-grade ledger. One that can only answer "what is the balance now?" is a liability.
Point-in-time queries require that posted_at be indexed and that your materialized view strategy supports historical snapshots. Consider a balance_snapshots table:
CREATE TABLE balance_snapshots (
snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(account_id),
snapped_at TIMESTAMPTZ NOT NULL,
balance NUMERIC(20,8) NOT NULL,
currency CHAR(3) NOT NULL
);
CREATE INDEX ON balance_snapshots (account_id, snapped_at DESC);
To reconstruct an arbitrary past balance, find the nearest snapshot before the target timestamp, then sum entries after that snapshot up to the target time. This is an O(log n) snapshot lookup plus an O(k) entry scan, where k is entries since last snapshot — typically very small.
For the entries table itself, a compound index on (account_id, created_at DESC) handles the 95th-percentile query pattern. A partial index on (txn_id) WHERE status = 'pending' in the transactions table eliminates full-table scans on the reconciliation worker's most common query.
Handling Multi-Currency and FX Entries
The naive approach — storing everything in a single amount column denominated in the account's home currency — collapses the moment a user holds EUR and GBP in the same wallet. The correct model keeps amounts in their original currency and records FX conversions as explicit ledger entries.
For a USD-to-EUR payment, you post four entries within one transaction:
- Debit the customer's USD asset account by the USD amount.
- Credit the USD liability account (your settlement pool) by the same USD amount.
- Debit the EUR liability account (your settlement pool) by the EUR equivalent.
- Credit the customer's EUR asset account by the EUR equivalent.
The FX spread — your revenue — is a fifth and sixth entry: a debit to the EUR liability and a credit to an FX revenue account. Every cent of spread is visible in the ledger as a posted entry, not hidden in a rounding field.
This design is validated by the approach taken in modern core-banking systems like Thought Machine's Vault and Mambu's ledger module, both of which treat each currency as an independent subledger with explicit conversion entries at the boundary.
Schema Decisions That Survive Scale
The jump from 10,000 to 10 million transactions per day exposes schema decisions that seemed harmless at launch. Three that matter most:
Partitioning by posted_at. PostgreSQL 14+ declarative partitioning on a TIMESTAMPTZ column is straightforward and pays off dramatically at high volume. A monthly partition scheme means reconciliation queries for a given month touch a single child table. Archive partitions older than your regulatory retention window (7 years for most US consumer payment records, per FinCEN requirements) to cheaper storage without touching the hot path.
UUID v7 over UUID v4. UUID v4 is random and causes index fragmentation at scale. UUID v7 (timestamp-ordered, standardized in RFC 9562, finalized in May 2024) preserves insertion order, dramatically reducing B-tree page splits. Postgres 17 ships with gen_random_uuid() still defaulting to v4 — implement a v7 generator via a small extension or a plpgsql function. The write throughput improvement at 1M+ rows/day is measurable: internal benchmarks at teams using UUID v7 report 20–35% reduction in index bloat versus v4 on append-heavy workloads.
Separate OLTP and OLAP schemas. Your reconciliation team and your BI team should not be running aggregate queries on the same Postgres instance as your payment API. Replicate to a read replica for analytics, or stream entries to a columnar store (Redshift, BigQuery, or ClickHouse) via logical replication. The Federal Reserve's FedNow Service Operator API documentation explicitly recommends separating operational and analytical ledger workloads for participants processing above 500 transactions per second.
Testing the Invariant Before Production
A double-entry constraint is only as good as the test suite that proves it holds under adversarial conditions. At minimum, your test battery should cover:
- Happy path: A standard debit/credit pair posts with
net = 0. - Missing credit: An insert of a debit-only entry is rejected at commit.
- Concurrent inserts: Two goroutines (or threads) race to post to the same account; the ledger balance is correct regardless of interleaving.
- Retry with idempotency key: A duplicate
idempotency_keyontransactionsreturns the original transaction rather than creating a second posting. - Reversal chain: A voided transaction generates a reversal entry pair, and the account balance returns to its pre-transaction state.
- FX entry completeness: A multi-currency transaction with a missing leg is rejected.
Property-based testing (Hypothesis in Python, fast-check in TypeScript) is particularly effective for ledger invariants: generate thousands of random entry combinations and assert that SUM(debits) = SUM(credits) for every posted transaction in the database.
Putting It Together with AtlasForge
The schema decisions above are language-agnostic and database-agnostic at the conceptual level, but they're built for the real world: PostgreSQL on managed infrastructure, with the immutability, audit, and multi-currency concerns encoded at the constraint layer rather than trusted to application code.
If you're building on top of a compliant ledger rather than from scratch, the AtlasForge Financial API exposes these primitives — immutable entries, idempotency-keyed transactions, and double-entry validation — as a managed service. Engineers on our platform have cut ledger implementation time from an average of 14 weeks to under 3, based on onboarding data from Q1 2027. For a deeper look at how the API handles partitioning, retention, and multi-currency subledgers, visit our developer documentation or read our platform overview.
The ledger is the source of truth. Get the schema right in week one, and every downstream system — reconciliation, reporting, compliance — inherits that correctness for free.
Further reading
Ready to build on AtlasForge?
Get sandbox API keys in 60 seconds — or install the Safe to Spend 365 app.
