Double Entry Bookkeeping for Developers: A Mental Model in Code
Double entry bookkeeping confuses most developers because every accounting tutorial buries it in jargon. Here's the mental model in code, with debits, credits, and the GL as a transaction log.
Most developers understand database transactions, append-only logs, and immutable event streams. Most developers also bounce off accounting tutorials within ten minutes because the tutorials open with “debits go on the left and credits go on the right” and never recover.
This is the post I wish someone had handed me before I started building ERPClaw. It is double entry bookkeeping explained as a system a developer would design, not as a 1494 manuscript by an Italian Franciscan monk. By the end you should understand the entire model well enough to read a journal entry, post one, and recognize when an ERP is doing it wrong.
This post is not for accountants. Accountants will find it shallow. It is for developers who need a working mental model in 20 minutes so they can ship code that does not corrupt financial data.
The mental model in one sentence
Double entry bookkeeping is an append-only log where every entry is a balanced pair of writes against two or more accounts, where the sum of writes always equals zero.
That is it. Everything else is naming conventions, sign conventions, and the rules people built on top.
The five account types as data buckets
An account is a labeled bucket that holds a running balance. There are five canonical types. Forget what they are called for a second and look at what they actually represent in a system you would design.
| Account type | What it represents | Example |
|---|---|---|
| Assets | Things the company owns | Cash, Inventory, Accounts Receivable |
| Liabilities | Things the company owes | Loans, Accounts Payable, Deferred Revenue |
| Equity | What the owners have invested or earned | Retained Earnings, Owner Capital |
| Revenue | Money the company earned (top line) | Product Sales, Subscription Revenue |
| Expenses | Money the company spent | Rent, Salaries, Cost of Goods Sold |
Every account in a chart of accounts is one of these five types. ERPClaw ships with 94 default accounts; QuickBooks ships with around 80; NetSuite lets you create thousands. Doesn’t matter, every one of them is one of these five types underneath.
The fundamental equation that ties them together:
Assets = Liabilities + Equity
Or if you split equity into its components:
Assets = Liabilities + (Owner Capital + Retained Earnings)
Retained Earnings = Cumulative Revenue - Cumulative Expenses
So really:
Assets = Liabilities + Owner Capital + Cumulative Revenue - Cumulative Expenses
This equation must always hold. Every transaction must preserve it. That is the invariant a double entry system enforces.
Why two writes per transaction
Imagine you record only one write per transaction. A customer pays you $100. You write Cash += 100. The cash bucket goes up. Done.
Now what? Where did the $100 come from? You have no idea. You broke the accounting equation. Assets went up by $100, but liabilities and equity did not change. The books no longer balance. You cannot generate a balance sheet because the balance sheet would not balance.
The fix is to require every transaction to record both sides of what happened. The customer paid you $100, which means cash went up AND something else changed. Maybe revenue went up (you sold them a thing). Maybe a liability went down (they paid off a bill). Maybe deferred revenue went up (they prepaid for a subscription). Whatever it is, you record it explicitly.
Transaction: customer pays $100 for a product
Cash += 100 (asset goes up)
Revenue += 100 (revenue goes up, which flows into equity via retained earnings)
Now the books balance. Assets went up by $100. Equity (via cumulative revenue) went up by $100. The equation holds.
This is double entry. Every transaction is two or more writes that together preserve the equation.
Debits and credits, finally
Now we can talk about debits and credits. The accounting world uses these two words because writing += and -= against five different account types gets confusing fast (some go up when you “add” and some go down when you “add” depending on context). Debit and credit are sign conventions that paper over the confusion.
Here are the only rules you need:
Debit = left column. Credit = right column. That is the visual convention.
For the five account types, debit and credit have these effects:
| Account type | Debit | Credit |
|---|---|---|
| Asset | increases | decreases |
| Liability | decreases | increases |
| Equity | decreases | increases |
| Revenue | decreases | increases |
| Expense | increases | decreases |
In every transaction, the total debits must equal the total credits. That is the rule.
Why are the signs flipped between asset and liability? Because of the equation. Assets on the left of the equation, liabilities on the right. Pushing the same number to both sides keeps the equation balanced. Debit increases the left side, credit increases the right side. The convention is just sign management.
The example transaction in debit/credit form:
Transaction: customer pays $100 for a product
DR Cash 100 (asset goes up by 100, so debit)
CR Revenue 100 (revenue goes up by 100, so credit)
Total debits: 100. Total credits: 100. Balanced.
Once you internalize the table above, every journal entry you ever read makes sense in about three seconds. The brain trick is that “debit” is just the technical word for “this side of the entry” and the effect on the balance depends on the account type.
A journal entry is a transaction in the database sense
In code, a journal entry is roughly this:
@dataclass
class JournalEntryLine:
account: str
debit: Decimal
credit: Decimal
# exactly one of debit/credit is non-zero per line
@dataclass
class JournalEntry:
id: str # UUID
posting_date: date
description: str
lines: list[JournalEntryLine]
def is_balanced(self) -> bool:
total_debit = sum(line.debit for line in self.lines)
total_credit = sum(line.credit for line in self.lines)
return total_debit == total_credit
def post(self, db):
if not self.is_balanced():
raise ValueError("Journal entry must balance")
with db.transaction():
for line in self.lines:
db.execute(
"INSERT INTO gl_entry (account, debit, credit, je_id) VALUES (?, ?, ?, ?)",
line.account, line.debit, line.credit, self.id,
)
That is the actual data model. A journal entry is a header plus N lines. Each line debits or credits one account. The entry is valid if total debits equal total credits. Posting it is one DB transaction that inserts all the lines atomically.
In ERPClaw, this is exactly how the gl_entry table works. Every line is one row. Every journal entry is N rows tied together by je_id. The constraint that debits equal credits is enforced at posting time. If a posting tries to write an unbalanced entry, the transaction rolls back and nothing hits the table.
Append-only and immutable, like a transaction log
Here is the part developers find genuinely interesting. The general ledger (GL) is append-only and immutable. You never update a posted entry. You never delete one. The same way a Kafka log or a git commit history works, the GL is a write-once record of what happened.
If you posted an entry and you need to “fix” it, you do not edit the original. You post a new entry that reverses the original (mirrors the debits and credits) and then post a corrected entry. The audit trail shows both: the original posting, the reversal, the correction. Nothing is hidden.
This matters because the GL is what your auditor reads. If posted entries could be edited or deleted, there would be no way to prove the books were not tampered with. The append-only constraint is the root of trust in financial accounting.
In ERPClaw, the gl_entry table has no updated_at column on purpose. There is no UPDATE statement anywhere in the codebase that targets it. There is no DELETE either. The only way to “remove” an entry is to post a reversal, which is a new entry with flipped debits and credits.
This is also why most accounting bugs in custom software are so painful. Developers come from environments where mutability is normal. They write UPDATE invoices SET amount = ... WHERE id = ... and the books fall apart silently. The fix is architectural: the GL is a log, not a state.
The income statement and balance sheet are derived state
Once you understand the GL is the source of truth and it is append-only, the financial reports are easy.
Trial balance. Group every entry by account, sum debits, sum credits. The balance per account is debits minus credits, signed by account type.
def trial_balance(db, as_of: date) -> dict[str, Decimal]:
rows = db.query(
"SELECT account, SUM(debit) AS d, SUM(credit) AS c "
"FROM gl_entry WHERE posting_date <= ? GROUP BY account",
as_of,
)
return {r.account: r.d - r.c for r in rows}
The trial balance must sum to zero across all accounts. If it does not, you have a bug.
Balance sheet. Take the trial balance, partition by account type, sum assets, liabilities, equity. Display.
Income statement. Take the trial balance, partition into revenue and expense accounts only, take credits minus debits for revenue and debits minus credits for expense, compute net income.
Cash flow statement. A bit more involved (you need to derive operating, investing, financing activities), but again derivable from the GL plus a category mapping on each account.
The financial reports are not stored. They are queries against the GL. Every report is reproducible from the same log. This is why accountants can sleep at night.
What the 12 step GL validation actually checks
ERPClaw runs every GL posting through a 12 step validation pipeline before it commits. Every step is a check that prevents a class of bug. For developers, here are the checks worth knowing about:
- All amounts are positive Decimals. No negative amounts anywhere. Reversals are handled by flipping debit and credit, not by negating the amount.
- Each line has exactly one of debit or credit, not both. A line that debits and credits the same account is meaningless.
- Total debits equal total credits. The fundamental balance check. Floats fail this. Decimals do not.
- At least two distinct accounts. A “transaction” that debits and credits the same account is a no-op.
- Posting date within an open period. You cannot post into a closed accounting period.
- Account exists and is not archived. No phantom accounts.
- Currency consistent within the entry. All lines in the same currency. Multi-currency is a separate concept handled with FX entries.
- Reference document exists if claimed. If the entry references invoice ABC, invoice ABC must exist.
- Reference document is not already posted. No double posting.
- Idempotency key is unique. The same operation cannot post twice if retried.
- All numeric values are Decimal, not float. Floats lose pennies. Decimals do not.
- GL state remains balanced after posting. Trial balance still sums to zero.
Any one failure rolls back the whole transaction. Nothing partial gets written.
This is what makes the GL trustworthy. The constraints are explicit, enforced at write time, and tested by the L0 constitutional test suite which catches regressions before they ship.
Why floats are a bug
In most ERPClaw code, money is stored as TEXT in SQLite and represented as Python Decimal in memory. Never as float. Never as int of cents.
The reason is the IEEE 754 floating point representation cannot exactly represent most decimal values. 0.1 + 0.2 == 0.3 is False in Python because both 0.1 and 0.2 are approximations. The error is small per operation but accumulates. Across thousands of GL entries, you end up with a trial balance that “almost” sums to zero, with a sub-penny error that is impossible to track down.
Decimal represents the exact decimal value. Decimal('0.1') + Decimal('0.2') == Decimal('0.3') is True. Storing as TEXT in SQLite preserves the string representation losslessly. This is non-negotiable.
from decimal import Decimal, ROUND_HALF_UP
amount = Decimal('100.05')
tax = (amount * Decimal('0.0825')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
# tax = Decimal('8.25')
# total = amount + tax = Decimal('108.30'), exactly
Any financial code that uses float is wrong. Any database schema that stores money as REAL is wrong. The constraint is mechanical and tested by ERPClaw’s L0 suite (test 023: “no float in money paths”).
What submit means in a draft to submit lifecycle
Most ERP entities have two lifecycle states: draft and submitted. The draft is editable. The submitted version is locked, posts to the GL, and triggers downstream actions.
The pattern in code:
def add_invoice(...) -> str:
"""Create a draft invoice. No GL posting. Editable."""
invoice_id = str(uuid.uuid4())
db.execute("INSERT INTO sales_invoice (id, status, ...) VALUES (?, 'draft', ...)", invoice_id, ...)
return invoice_id
def submit_invoice(invoice_id: str):
"""Validate, post GL, lock the invoice. Single transaction."""
with db.transaction():
invoice = load_invoice(invoice_id)
validate_for_submit(invoice)
gl_entry = build_gl_entry(invoice)
run_12_step_validation(gl_entry)
post_gl(gl_entry)
update_inventory(invoice)
update_ar_aging(invoice)
db.execute("UPDATE sales_invoice SET status = 'submitted' WHERE id = ?", invoice_id)
The whole submit operation is one DB transaction. Any failure rolls back everything. The invoice does not become submitted unless every cross-table write succeeds. There is no partial state.
This is the data integrity guarantee. Either the invoice is fully submitted with GL, inventory, and AR updated, or none of it happened. No “the invoice is submitted but the GL did not post.” That state is unreachable.
The whole thing in 100 words
Double entry bookkeeping is an append-only log of balanced transactions against five account types (assets, liabilities, equity, revenue, expenses) where every transaction preserves Assets = Liabilities + Equity + Revenue - Expenses. Each transaction is two or more writes that sum to zero in debit/credit terms. The GL is immutable; corrections are reversals plus new entries. Reports are derived state, not stored. Money is Decimal, not float. Every posting is one DB transaction that either fully commits or fully rolls back. That is the model. Everything else is convention or jargon.
Where to read more
If you want the actual code, ERPClaw is open source. The GL posting code lives in erpclaw_lib/gl/ in the shared library. The 12 step validation lives in validate_gl.py. The L0 constitutional tests live in testing/l0/.
For the conceptual reading, the AICPA’s free guides are decent and short. Ignore textbooks. They are written for accounting students, not developers, and they bury this 100 word model in 600 pages of jargon.
CTA
If you are building software that touches financial data and you want to see how a working open-source ERP implements the constraints in this post, ERPClaw is open source, AI-native, and runs on SQLite or PostgreSQL via PyPika.
- Install ERPClaw (5 minutes)
- Browse the source on GitHub
- Read about why we chose SQLite
- See the engineering blog
- Run the demo
The 46 module suite is free, the GL implementation is open, and the test suite that enforces all of the above is on GitHub. If you find a bug in the GL validation, file an issue. If you have a 13th check we should add, send a pull request.
FAQ
Do I really need to use Decimal everywhere or can I use int cents?
Int cents (storing $100.05 as 10005) works for currencies with two decimal places and no rounding edge cases. It breaks the moment you compute tax (which produces fractional cents that need to round) or apply a percentage discount. Decimal handles all of these correctly with explicit rounding rules. Use Decimal.
Why is the GL append-only instead of using soft deletes or version columns?
Soft deletes and version columns require a query that filters on “the latest version” or “not deleted” to read the current state. That filter is easy to forget. The append-only constraint with no UPDATE or DELETE is enforced by the database schema itself, not by query convention. It is impossible to accidentally read stale data.
How do I undo a posted journal entry without violating immutability?
Post a reversal. The reversal is a new journal entry that mirrors the original (debits become credits and vice versa). The original posting plus the reversal nets to zero. If you also need to post a corrected entry, post that as a third entry. The audit trail shows all three.
What about multi-currency? Do amounts still need to balance?
Yes. Multi-currency entries balance in their original currency and also in the reporting currency, which means each line carries an FX rate at the time of posting. The reporting currency totals are derived. Currency mismatches across lines (e.g., one line in EUR and another in USD) require an explicit FX clearing account to balance. ERPClaw v1 is USD-only; multi-currency is on the roadmap.
Is double entry overkill for a one-person business?
It depends on whether you ever want to take the business seriously. If you ever need a bank loan, raise capital, sell the business, or get audited, you need double entry. Most personal finance tools are single entry (cash in, cash out) and that is fine for personal use. The moment a business has receivables, payables, inventory, or deferred revenue, single entry stops being able to represent reality.
Can I implement this in Postgres or do I need SQLite?
Either works. ERPClaw is database-agnostic via PyPika and supports both. The append-only constraint is enforced in application code; the schema only needs to support transactions and unique constraints. Most relational databases qualify.
Why is “credit” sometimes positive and sometimes negative depending on the report?
Different reports flip the sign convention to make numbers look intuitive. On the income statement, credit revenue is shown as positive (because revenue is “good”). On the balance sheet, credit liabilities are shown as positive (because they are an obligation). Internally the GL stores debit and credit as separate columns. The report layer applies the sign convention. The internal data is unambiguous.
Install ERPClaw and read the GL code if you want to see this implemented in production-grade open source.
Related posts
Agency Accounting, Project P&L, and Time Billing: A Real Guide
Why your friendly retainer client is secretly losing you $40 an hour, the four metrics every agency owner should know, and how to fix project P&L.
A2X Alternative: The Free Open-Source Tool Most Shopify Stores Don't Know About
Looking for an A2X alternative? ERPClaw uses the same clearing account method, books every order separately, includes per-warehouse stock costs, and costs $0. A founder's honest comparison.
AI Decorated vs AI Native Software: Why Most AI Features Will Lose
AI-decorated tools bolt a chatbot onto 2015 software and charge a new fee. AI-native software rebuilds the architecture. One of these wins. Here is why.