Double-Entry Accounting in a Relational Database: Key Tables and Constraints
Double-entry accounting is the bedrock of every financial system. Every transaction debits one account and credits another, and at all times the sum of all account balances must be zero. Getting this model right in a relational database is tricky — get the constraints wrong and you can silently create or destroy money.
Here is the schema and the logic that makes it safe.
Core tables
accounts
Every account has a type that determines its normal balance side:
CREATE TYPE account_type AS ENUM (
'asset', 'liability', 'equity', 'income', 'expense'
);
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE,
name TEXT NOT NULL,
type account_type NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
normal_side CHAR(1) GENERATED ALWAYS AS (
CASE type
WHEN 'asset' THEN 'D'
WHEN 'expense' THEN 'D'
ELSE 'C'
END
) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The normal_side computed column encodes the accounting rule: assets and expenses increase with debits; liabilities, equity, and income increase with credits.
journal_entries
A journal entry is a single economic event — one invoice, one payment, one adjustment:
CREATE TABLE journal_entries (
id BIGSERIAL PRIMARY KEY,
description TEXT NOT NULL,
posted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Every journal entry must be balanced before it is posted. The application layer should enforce this, but a check constraint on the posting operation is a good safety net.
journal_lines
Each entry has two or more lines. A simple payment has two; complex allocations may have many:
CREATE TABLE journal_lines (
id BIGSERIAL PRIMARY KEY,
entry_id BIGINT NOT NULL REFERENCES journal_entries(id)
ON DELETE RESTRICT,
account_id BIGINT NOT NULL REFERENCES accounts(id)
ON DELETE RESTRICT,
side CHAR(1) NOT NULL CHECK (side IN ('D', 'C')),
amount NUMERIC(20,4) NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
memo TEXT
);
CREATE INDEX idx_journal_lines_entry_id ON journal_lines(entry_id);
CREATE INDEX idx_journal_lines_account_id ON journal_lines(account_id);
Key points:
amountis always positive. Debit or credit is determined byside, not by sign.ON DELETE RESTRICTprevents deleting accounts or entries that have lines — an audit requirement.entry_id+sidealone does not enforce balance; that needs a constraint or application check.
The golden constraint: every entry must balance
The defining invariant: for every entry, SUM(amount) where side = 'D' must equal SUM(amount) where side = 'C'.
You can enforce this at the database level with a deferred assertion. PostgreSQL does not have CREATE ASSERTION yet, but you can use a deferred trigger:
CREATE FUNCTION check_entry_balance() RETURNS TRIGGER AS $$
BEGIN
IF (
SELECT COALESCE(SUM(CASE WHEN side = 'D' THEN amount ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN side = 'C' THEN amount ELSE 0 END), 0)
FROM journal_lines
WHERE entry_id = COALESCE(NEW.entry_id, OLD.entry_id)
) != 0 THEN
RAISE EXCEPTION 'Journal entry % is not balanced', COALESCE(NEW.entry_id, OLD.entry_id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER trg_journal_lines_balanced
AFTER INSERT OR UPDATE OR DELETE ON journal_lines
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_entry_balance();
Deferring the check allows inserting all lines of an entry in a single transaction without worrying about intermediate imbalance.
Account balances: running totals vs materialised
There are two schools of thought:
1. Compute on the fly — Simple and always correct, but slow at scale:
SELECT a.id, a.code, a.name,
COALESCE(SUM(CASE WHEN jl.side = 'D' THEN jl.amount ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN jl.side = 'C' THEN jl.amount ELSE 0 END), 0) AS balance
FROM accounts a
LEFT JOIN journal_lines jl ON jl.account_id = a.id
GROUP BY a.id, a.code, a.name;
2. Materialised balance table — Fast reads, but needs careful synchronisation:
CREATE TABLE account_balances (
account_id BIGINT PRIMARY KEY REFERENCES accounts(id),
balance NUMERIC(20,4) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Update it with a trigger on journal_lines. Batch updates are safer than row-by-row to avoid race conditions.
If you need point-in-time balances (e.g., “what was the balance on April 1?”), keep a running ledger instead — a materialised view that stores cumulative balances at period boundaries:
CREATE TABLE running_ledger (
account_id BIGINT NOT NULL REFERENCES accounts(id),
as_of_date DATE NOT NULL,
balance NUMERIC(20,4) NOT NULL,
PRIMARY KEY (account_id, as_of_date)
);
This is the most common approach in production accounting systems.
Currency and multi-currency
If you support multiple currencies, store the currency on every line and the exchange rate used:
ALTER TABLE journal_lines ADD COLUMN fx_rate NUMERIC(10,6) NOT NULL DEFAULT 1;
ALTER TABLE journal_lines ADD COLUMN base_amount NUMERIC(20,4) GENERATED ALWAYS AS (amount * fx_rate) STORED;
Base-currency balancing is then enforced via base_amount instead of amount.
Common pitfalls
- Using signed amounts with
+/-instead of asidecolumn. This inevitably leads to sign confusion and off-by-one bugs. - Omitting currency columns — you will need them, and adding them later is painful.
- No audit trail — never
UPDATEorDELETEposted entries. Use reversing entries instead. - Enforcing balance at the application layer only — a bug in code can create orphan debits. The database should be the last line of defence.
- Integer vs numeric types — always use
NUMERICfor money. Floating-point rounding will destroy your books.
A note on soft-delete and immutability
Posted journal entries should be immutable. If a correction is needed, post a reversing entry:
-- Reverse a misposted entry
INSERT INTO journal_entries (description) VALUES ('Reversal of entry #123');
INSERT INTO journal_lines (entry_id, account_id, side, amount, currency)
-- same lines with opposite side
SELECT new_entry.id, account_id,
CASE WHEN side = 'D' THEN 'C' ELSE 'D' END,
amount, currency
FROM journal_lines
WHERE entry_id = 123;
A posted_at timestamp and a reversed_by nullable FK on journal_entries makes the chain traceable.
Conclusion
A correct double-entry model is defined by three things: a side column (not signed amounts), a balance constraint enforced at the DB level, and immutable journal entries. Everything else — currency handling, materialised balances, point-in-time reporting — builds on that foundation.
Design the schema as if an off-by-one error costs real money, because in production it will.