Skip to content
Published on

Banking Ledger Design — Double-Entry Bookkeeping, Event Sourcing, and Consistency Down to the Last Cent

Authors

Introduction — When One Cent Is Off, the Bank Stops

The first thing that surprises developers new to banking systems is this fact: if the balance is off by even one cent, end-of-day close does not complete. In a typical web service, you can tolerate slightly inconsistent data, run a correction batch, and move on. A bank ledger is different. The ledger is the legal record of customer assets, the subject of accounting audits and regulatory examinations, and the moment debits and credits stop balancing, that day of business cannot be closed.

In this article, we assume we are designing a banking ledger from scratch and walk through double-entry fundamentals, the data model, balance computation strategies, event sourcing, concurrency control, and consistency problems in distributed environments. The content applies equally to fintech startups building internal ledgers or anyone designing ledger-like systems for points, stored value, or wallets.

A note up front: this is a technical article about system design, not accounting, tax, or legal advice. For actual financial licensing or accounting treatment, always consult professionals and the applicable regulations.

Why the Ledger Is the Source of Truth of a Bank

In a core banking system, the ledger is the final record of every transaction. All other data — the balance shown on a screen, analytical data in the data warehouse, the transaction history in a mobile app — is merely a derived copy of the ledger.

There are three reasons the ledger must be the source of truth.

  1. Legal evidentiary value: When disputes occur, the ledger record is the legal basis. Records must therefore be immutable, and changes happen only by appending correction records, never by editing.
  2. Accounting integrity: A bank financial statement is aggregated from the ledger. The total of debits and the total of credits in the General Ledger must always match.
  3. Audit traceability: It must be possible to reconstruct who recorded which transaction, when, and on what basis.

These three requirements drive every decision in ledger design — the append-only structure, double-entry bookkeeping, idempotency, and audit columns.

Double-Entry Basics — Debits and Credits

Double-entry bookkeeping records every transaction in at least two accounts simultaneously. One side is a debit, the other a credit, and within a single transaction the sum of debit amounts must equal the sum of credit amounts.

Accounts fall into five broad categories, and the category determines whether an increase is recorded as a debit or a credit.

Account categoryExamplesIncrease recorded asDecrease recorded as
AssetCash, loan receivablesDebitCredit
LiabilityCustomer depositsCreditDebit
EquityShare capitalCreditDebit
RevenueInterest income, fee incomeCreditDebit
ExpenseInterest expenseDebitCredit

Here is a point that frequently confuses developers. Customer deposits are a liability from the bank perspective, because money entrusted by customers is debt that must eventually be returned. Conversely, loans are an asset (money to be received) from the bank perspective.

As an example, the journal entry for customer A depositing 1,000,000 KRW in cash looks like this.

AccountDebitCredit
Cash (asset increase)1,000,000 KRW
Customer deposits (liability increase)1,000,000 KRW

An internal transfer of 300,000 KRW from customer A to customer B looks like this.

AccountDebitCredit
Customer deposits - A (liability decrease)300,000 KRW
Customer deposits - B (liability increase)300,000 KRW

Collecting 50,000 KRW of loan interest looks like this.

AccountDebitCredit
Customer deposits - A (liability decrease)50,000 KRW
Interest income (revenue increase)50,000 KRW

The invariant that debit totals equal credit totals in every journal entry — called a balanced entry — is the core of ledger integrity. This invariant must be enforced in three layers: application code, database constraints, and batch verification.

The Ledger Data Model — Journal, Postings, Balances

A ledger model proven in practice consists of three core tables.

  • journal_entries (entry header): One business transaction. It carries the transaction ID, transaction type, occurrence time, and idempotency key.
  • postings (entry lines): The individual debit and credit records that make up an entry. At least two lines belong to each entry.
  • account_balances: A table holding the current balance per account, in the spirit of a materialized view.

Here is an example schema for PostgreSQL.

-- Account master
CREATE TABLE accounts (
    account_id      BIGINT PRIMARY KEY,
    account_no      VARCHAR(20) NOT NULL UNIQUE,
    account_type    VARCHAR(10) NOT NULL,  -- ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE
    currency        CHAR(3) NOT NULL DEFAULT 'KRW',
    status          VARCHAR(10) NOT NULL DEFAULT 'ACTIVE',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Entry header (journal)
CREATE TABLE journal_entries (
    entry_id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    txn_id          UUID NOT NULL UNIQUE,          -- idempotency key
    txn_type        VARCHAR(30) NOT NULL,          -- DEPOSIT, TRANSFER, INTEREST ...
    business_date   DATE NOT NULL,                 -- value date (business-day based)
    posted_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    reversal_of     BIGINT REFERENCES journal_entries(entry_id),
    description     TEXT,
    created_by      VARCHAR(50) NOT NULL
);

-- Entry lines (postings) — append-only, no UPDATE/DELETE
CREATE TABLE postings (
    posting_id      BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    entry_id        BIGINT NOT NULL REFERENCES journal_entries(entry_id),
    account_id      BIGINT NOT NULL REFERENCES accounts(account_id),
    direction       CHAR(1) NOT NULL CHECK (direction IN ('D', 'C')),
    amount          NUMERIC(19, 4) NOT NULL CHECK (amount > 0),
    currency        CHAR(3) NOT NULL,
    business_date   DATE NOT NULL
);

CREATE INDEX idx_postings_account_date
    ON postings (account_id, business_date, posting_id);

-- Per-account balance (derived data)
CREATE TABLE account_balances (
    account_id      BIGINT PRIMARY KEY REFERENCES accounts(account_id),
    balance         NUMERIC(19, 4) NOT NULL DEFAULT 0,
    last_posting_id BIGINT NOT NULL DEFAULT 0,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

Let us walk through the design points.

  1. Amounts use NUMERIC (or DECIMAL): Floating point (FLOAT, DOUBLE) is strictly forbidden. You cannot handle money in a world where 0.1 + 0.2 is not 0.3. Storing integers in the smallest currency unit (won, cents) is also widely used.
  2. Separate direction and amount: Compared to expressing debit and credit with signed amounts, a direction column plus positive amounts makes aggregation queries and verification much clearer.
  3. Separate business_date (value date) and posted_at (system time): A transaction arriving at 23:59 may be valued on the next business day; the accounting date and the physical recording time can differ.
  4. Postings are append-only: Block UPDATE and DELETE at the database privilege level.
-- Application role gets INSERT/SELECT only
REVOKE UPDATE, DELETE ON postings FROM app_user;
GRANT INSERT, SELECT ON postings TO app_user;

Balance Computation Strategies — Sum vs Real-Time Balance vs Snapshot

There are three broad ways to compute an account balance, each with different trade-offs.

StrategyMethodProsCons
Posting summationSUM query every timeAlways correct, no derived stateSlows down as transactions accumulate
Real-time balance tableUpdate balance per transactionO(1) reads, immediate balance checksUpdate contention, dual consistency management
Snapshot plus deltaStore EOD balance, sum only the delta sinceFast reads and verifiableRequires snapshot batch operations

In practice you combine all three. At transaction time you update the balance table (to block insufficient funds), at end of day you take a snapshot, and a verification batch reconciles posting sums against the balance table.

A snapshot-based balance query looks like this.

-- Yesterday closing snapshot + today delta = current balance
SELECT s.balance
     + COALESCE(SUM(
         CASE WHEN p.direction = 'C' THEN p.amount
              ELSE -p.amount END), 0) AS current_balance
FROM balance_snapshots s
LEFT JOIN postings p
       ON p.account_id = s.account_id
      AND p.business_date > s.snapshot_date
WHERE s.account_id = 1234567
  AND s.snapshot_date = (
      SELECT MAX(snapshot_date) FROM balance_snapshots
      WHERE account_id = 1234567)
GROUP BY s.balance;

For liability accounts (deposits), a credit means increase, so the query adds C and subtracts D as above; for asset accounts the signs are reversed. If the sign rules per account category are not centralized in one place, bugs are guaranteed.

Event Sourcing and the Ledger — Corrections via Reversal Entries

A ledger is essentially the same structure as event sourcing. The immutable append-only journal is the event stream, and the balance is derived state obtained by folding over the events. What accounting has been doing for centuries is exactly event sourcing.

The most important principle is: never modify a record. A wrong journal entry is not fixed with an UPDATE; instead, you append a reversal entry that offsets the original, then record the correct entry again.

def reverse_entry(conn, original_entry_id: int, reason: str, operator: str):
    """Create a reversal entry that offsets the original entry.

    For each posting of the original, create a posting with the
    direction (D/C) flipped. Never modify the original data.
    """
    with conn.cursor() as cur:
        # Check whether this entry was already reversed (prevent double reversal)
        cur.execute(
            "SELECT 1 FROM journal_entries WHERE reversal_of = %s",
            (original_entry_id,))
        if cur.fetchone():
            raise AlreadyReversedError(original_entry_id)

        cur.execute(
            """INSERT INTO journal_entries
                   (txn_id, txn_type, business_date,
                    reversal_of, description, created_by)
               SELECT gen_random_uuid(), 'REVERSAL', CURRENT_DATE,
                      entry_id, %s, %s
               FROM journal_entries WHERE entry_id = %s
               RETURNING entry_id""",
            (reason, operator, original_entry_id))
        reversal_id = cur.fetchone()[0]

        # Copy postings with direction flipped
        cur.execute(
            """INSERT INTO postings
                   (entry_id, account_id, direction, amount,
                    currency, business_date)
               SELECT %s, account_id,
                      CASE direction WHEN 'D' THEN 'C' ELSE 'D' END,
                      amount, currency, CURRENT_DATE
               FROM postings WHERE entry_id = %s""",
            (reversal_id, original_entry_id))
    return reversal_id

The advantages of the reversal approach are clear.

  • Both the original record and the correction record remain, so the audit trail is complete.
  • Balance recomputation logic is simple. Summing all postings always yields the current state.
  • You can reconstruct the balance as of any point in time (point-in-time queries).

One thing to be careful about is the date on which the reversal occurs. Even if the original was yesterday, the reversal is normally recorded with today as the value date. Changing the numbers of an already-closed business day would retroactively alter reports and accounting books.

Concurrency Control — The Hot Account Problem

Individual customer accounts rarely see concurrent transactions, but accounts like fee income or internal suspense accounts can attract thousands of postings per second. These are called hot accounts.

In a design that updates the balance table per transaction, hot accounts become the center of lock contention.

[Hot account lock contention]

  txn1 ──┐
  txn2 ──┤  all waiting to          UPDATE account_balances
  txn3 ──┼─ update the same row ──▶ SET balance = balance + ?
  ...   ──┤                         WHERE account_id = 9999  (fee account)
  txnN ──┘
            ──▶ serialized: throughput capped by single-row update speed

Apply mitigation strategies step by step.

  1. Reconsider whether synchronous balance updates are truly needed: A fee income account needs no limit checks, so you can record only the postings and aggregate the balance asynchronously. Update synchronously only for accounts that need overdraft or limit checks.
  2. Fix the lock ordering: When a transfer updates two accounts, always acquire locks in ascending account_id order to prevent deadlocks.
  3. Sub-account sharding: Split the hot account into N sub-accounts, hash-distribute the postings, and sum at read time.
-- Fixed lock ordering example: always smaller account_id first
SELECT * FROM account_balances
WHERE account_id IN (1001, 2002)
ORDER BY account_id
FOR UPDATE;
[Sub-account sharding]

  Fee income account (logical)
        ├── fee-income-00  ◀─ hash(txn_id) % 4 == 0
        ├── fee-income-01  ◀─ hash(txn_id) % 4 == 1
        ├── fee-income-02  ◀─ hash(txn_id) % 4 == 2
        └── fee-income-03  ◀─ hash(txn_id) % 4 == 3

  Read: SUM of the 4 sub-account balances
  EOD: optionally consolidate sub-accounts into the main account

The price of sharding is that you cannot obtain the exact real-time balance of the logical account with a single-row read. It is hard to apply to accounts that need limit checks and is mostly used for aggregate-style internal accounts.

Idempotency and Duplicate Prevention — Transaction ID Design

Retries are everyday life in financial systems. When a client retries after a network timeout, a transfer must not execute twice. The key is to enforce the client-generated transaction ID (idempotency key) with a unique constraint on the journal.

-- The UNIQUE constraint on txn_id blocks duplicate booking at the DB level
INSERT INTO journal_entries (txn_id, txn_type, business_date, created_by)
VALUES ('3fa85f64-5717-4562-b3fc-2c963f66afa6', 'TRANSFER', CURRENT_DATE, 'api')
ON CONFLICT (txn_id) DO NOTHING
RETURNING entry_id;
-- Empty RETURNING means the transaction was already processed
-- → look up and return the original result

Things to check when designing transaction IDs.

  • Who generates it: The caller (channel system, external institution) must generate it so the ID is identical across retries. If the server generates it, idempotency breaks.
  • Scope and lifetime: With a combination of institution code, date, and sequence number (common in financial messaging), check whether IDs are reused when the date rolls over and whether the unique constraint must include the date.
  • Response replay: A duplicate request should receive the same response as the first processing, not an error. Store the processing result so it can be looked up by transaction ID.

Consistency Verification — Trust, but Verify

Ledger invariants are not maintained by code alone. Deployment accidents, manual data fixes, and bugs can break consistency at any time, so verification must run continuously.

1. Per-entry balance check — Confirm debit totals equal credit totals in every entry.

SELECT entry_id,
       SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS debit_sum,
       SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS credit_sum
FROM postings
WHERE business_date = CURRENT_DATE
GROUP BY entry_id
HAVING SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END)
    <> SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END);
-- Zero rows means healthy

2. Trial balance check — Confirm the grand total of debits equals the grand total of credits across the whole ledger.

3. Balance table reconciliation — Compare account_balances values against posting sums per account. Mismatched accounts trigger alerts, and the first step is root cause investigation, not automatic correction.

4. External reconciliation — For interbank transfers, reconcile against clearing house records; for card acquiring, against card network settlement files. Internal consistency and external consistency are separate problems.

Run the verification batch immediately before and after EOD close, and make it a gate that halts the close when the difference is not zero.

The Distributed Ledger Problem — Double Booking in the Microservices Era

When the ledger service and the payment service are separate microservices, partial failures occur: the payment succeeds but the ledger booking fails. Distributed transactions (2PC) are increasingly avoided even in finance due to availability and operational cost, and the saga pattern with compensating transactions has become the standard approach.

[Transfer saga — happy path and compensation path]

  Channel ──▶ Transfer orchestrator
              ├─ 1. Book withdrawal (customer account D / suspense C)
              │       success
              ├─ 2. Request external remittance (clearing house / other bank)
              │       │
              │       ├─ success ──▶ 3. Clear suspense (suspense D / interbank claim C)
              │       │
              │       └─ failure ──▶ 3'. Compensate: reverse withdrawal
              │                          (suspense D / customer account C)
              └─ Every step is idempotent — retrying the same txn_id
                 yields the same outcome

Design principles are as follows.

  • Model suspense accounts explicitly. When the "money in flight" state is expressed as an account, even intermediate saga states keep debits and credits balanced.
  • Compensation means reversal, not deletion. Failed flows also remain in the record.
  • A timeout is not a failure. When an external institution times out, the outcome is unknown; keep the transaction in an UNKNOWN state and confirm via reconciliation or status inquiry before deciding to compensate. A classic cause of financial incidents is double processing: treating a timeout as a failure, compensating, and then discovering the original also succeeded.

Integrating with Accounting — From Core Banking to the General Ledger

The customer-level transaction ledger (sub-ledger) and the financial accounting General Ledger (GL) are usually separate. You do not push millions of core banking transactions straight into the GL; instead, an EOD batch produces aggregated journal entries grouped by account code, organization, and currency, and posts them to the GL daily.

[Core banking → General Ledger interface]

  Core banking ledger (per transaction, millions/day)
      ▼  EOD batch: GROUP BY account code x department x currency
  Aggregated journal file (thousands/day)
      ▼  GL interface (validation: balanced entries, mapping exists)
  General Ledger (for financial reporting)

The trap here is missing mappings. When a new product or transaction type is added without a GL account mapping, the aggregation batch either fails (fortunate) or silently drops the unmapped portion (disastrous). It is safer to make the batch fail when it finds a transaction type without a mapping.

Audit Trail — Who, When, Why

Beyond the transaction itself, ledger data needs the following metadata.

  • Recording party: channel (mobile/branch/batch), operator ID, approver ID
  • Basis: reference to the original transaction, reversal reason, related message ID
  • Time: system time, value date, and channel request time when needed

Electronic financial transaction records carry multi-year retention obligations under regulations such as Korea's Regulation on Supervision of Electronic Financial Transactions (one year, five years, and so on depending on the type). Decide retention design together with the compliance team, and consider partitioning and archiving strategies from the start.

Common Bug Patterns

These are ledger bug patterns encountered frequently in the field.

  1. Using floating point: Interest computed in double accumulates error until EOD is off by one unit. Use decimal-precision types end to end.
  2. Scattered sign rules: The rule that assets increase on the debit side is hardcoded in both service A and batch B, and only one gets fixed. Centralize sign rules in a single module.
  3. New transaction ID per retry: The client SDK generates a fresh UUID on every retry, neutralizing idempotency.
  4. Reversal of a reversal: The same entry gets reversed twice and the balance tips the other way. Block with a unique constraint on reversal existence.
  5. Backdated postings after close: Postings land on an already-closed business day, so reports and ledger disagree. You need a close guard on business_date.
  6. Time zone issues: UTC and local time mix, and value dates around midnight come out wrong. Centralize value-date determination logic.

Design Checklist

  • Are amount types decimal-precise (NUMERIC/DECIMAL or smallest-unit integers)?
  • Are postings append-only with UPDATE/DELETE blocked by DB privileges?
  • Is the balanced-entry invariant verified both in the application and in batch?
  • Are transaction IDs generated by the caller and deduplicated via a unique constraint?
  • Do duplicate requests replay the original response?
  • Are corrections done only via reversal entries, with double reversal blocked?
  • Are value date and system time separated, with backdating into closed days blocked?
  • Have hot accounts been identified and synchronous balance updates minimized?
  • Is a reconciliation batch running between the balance table and posting sums?
  • Are intermediate states of distributed transactions modeled with suspense accounts?
  • Are timeouts treated as unknown outcomes confirmed via reconciliation?
  • Does the batch fail when GL mappings are missing?
  • Are audit metadata (party, basis, time) and retention periods defined?

Closing Thoughts

The essence of ledger design is not flashy technology but relentless enforcement of invariants. The centuries-old invariant of double-entry bookkeeping, the simple rule of append-only storage, and idempotency as the fundamental skill of distributed systems — enforce these three in code, schema, and batch verification, and consistency down to the last cent will hold. Conversely, postpone any one of those layers with a casual "later", and the cost will inevitably return as an EOD incident in the small hours.

In the next article, we cover the batch and end-of-day (EOD) architecture that runs on top of this ledger.

References