Why Single Balance Tables Break
The pattern is always the same. You start with a users table that has a balance column. A payment comes in, you run UPDATE users SET balance = balance + 100 WHERE id = ?. It works great for your first thousand users.
Then things get interesting. Two concurrent requests hit the same user. One is a deposit, one is a withdrawal. Without careful locking, you get a race condition — the classic lost update problem. I've personally debugged a production incident where a user's balance was off by $4,200 because two goroutines read the same balance, computed different results, and the last write won.
But race conditions aren't even the worst part. The real problem is that you have no idea why the balance is what it is. When a customer disputes a charge and you need to reconstruct what happened, all you have is a number. No trail. No history. Just a balance that may or may not be correct.
- Race conditions — concurrent updates corrupt balances without row-level locking or serializable transactions
- No audit trail — you can't explain how a balance reached its current value
- Reconciliation nightmares — when your balance doesn't match the payment processor's, you have no way to find the discrepancy
- Correction is destructive — fixing a wrong balance means overwriting the old value, losing the evidence of what went wrong
The Double-Entry Principle Applied to Payments
Double-entry bookkeeping has one rule: every transaction must have equal debits and credits. Money doesn't appear or disappear — it moves between accounts. In a payment platform, this means every operation creates at least two ledger entries that sum to zero.
Entry
When a customer deposits $50, you don't just increment their balance. You create a journal entry with two lines: a debit from the payment processor's liability account and a credit to the customer's wallet account. The sum is always zero. If it's not, something is broken — and you'll know immediately.
This isn't just accounting theory. It's a runtime invariant you can check on every single transaction. I run a background job that sums all ledger entries every hour. If the total isn't zero, we get paged. It's caught bugs in staging that would have been invisible with a balance table.
Journal Entry Design in Go
Here's the core data model I've used across two production ledger systems. The key insight is that a JournalEntry is the atomic unit — it groups multiple LedgerLine records that must all succeed or all fail.
type JournalEntry struct {
ID string `db:"id"` // UUID, immutable
IdempotencyKey string `db:"idempotency_key"` // prevents duplicate posts
EntryType string `db:"entry_type"` // "deposit", "withdrawal", "transfer", "correction"
Description string `db:"description"`
Metadata JSONB `db:"metadata"` // payment processor ref, user context
CreatedAt time.Time `db:"created_at"`
}
type LedgerLine struct {
ID string `db:"id"`
JournalEntryID string `db:"journal_entry_id"`
AccountID string `db:"account_id"`
Amount int64 `db:"amount"` // in cents, positive = credit, negative = debit
Currency string `db:"currency"` // ISO 4217
CreatedAt time.Time `db:"created_at"`
}
type Account struct {
ID string `db:"id"`
AccountType string `db:"account_type"` // "user_wallet", "revenue", "processor_liability"
OwnerID string `db:"owner_id"` // user ID or system identifier
Currency string `db:"currency"`
}
A few things worth noting. The Amount field is an int64 in cents — never use floats for money. The IdempotencyKey on the journal entry is critical: if a network retry causes the same deposit to be posted twice, the unique constraint on this field catches it. And Metadata as a JSONB column gives you a place to stash the Stripe payment intent ID, the internal request ID, whatever you need for debugging later without polluting the schema.
Real-world gotcha: We once had a bug where a refund handler created ledger lines but crashed before committing the journal entry. The lines were orphaned — they existed in the database but weren't linked to any entry. Our balance derivation query included them, so the user's balance was wrong for 6 hours. The fix: wrap journal entry + all ledger lines in a single database transaction, and add a foreign key constraint with ON DELETE CASCADE. Orphaned lines become structurally impossible.
Deriving Balances vs. Storing Them
This is where most teams get nervous. "You want me to SUM the entire ledger every time someone checks their balance?" Yes — with caveats.
The query is simple: SELECT SUM(amount) FROM ledger_lines WHERE account_id = ?. That's the source of truth. But at scale, you'll want a materialized balance that's updated transactionally alongside new ledger lines. The trick is that the materialized balance is a cache, not the source of truth. You can always rebuild it.
In practice, I use both. The ledger is the source of truth. A balance_snapshots table caches the computed balance, updated inside the same transaction that inserts new ledger lines. A nightly job recomputes all balances from scratch and flags any that don't match the snapshot. In two years, it's caught exactly one discrepancy — and that was from a manual database edit someone made during an incident.
Immutable Entries and Corrections
This is non-negotiable: ledger entries are immutable. You never update or delete a row in the ledger. Ever. If something is wrong, you post a correcting entry — a new journal entry that reverses the original.
Say a customer was charged $100 but should have been charged $80. You don't go back and edit the original entry. You create a new journal entry: credit the customer $100 (reversing the original), then debit $80 (the correct charge). The ledger now tells the full story — what happened, what was wrong, and how it was fixed.
Enforce this at the database level. Remove UPDATE and DELETE grants on the ledger tables for your application's database user. Use PostgreSQL's row-level security or a trigger that rejects mutations. I've seen well-intentioned engineers "fix" ledger data with direct SQL updates during incidents, and it always makes things worse.
Lifecycle of a Payment Through the Ledger
Here's what happens when a customer pays $50 to a merchant on your platform, end to end:
The advisory lock in step 2 is worth calling out. We use pg_advisory_xact_lock keyed on the account ID. It's scoped to the transaction, so it auto-releases on commit or rollback. This is lighter than SELECT ... FOR UPDATE on the account row and doesn't cause deadlocks when multiple accounts are involved in a transfer — as long as you always lock in a consistent order (lowest account ID first).
Practical Tips from Production
- Use
int64cents, not floats. I shouldn't have to say this in 2026, but I still seefloat64for money in code reviews. Use the smallest currency unit. For USD, that's cents. For JPY, it's yen (no subunit). Store the currency code alongside every amount. - Partition your ledger table by month. After a year of moderate traffic, our ledger_lines table had 80 million rows. Queries against recent data were fine, but the nightly full reconciliation was taking 45 minutes. PostgreSQL declarative partitioning by
created_atmonth brought it down to 3 minutes. - Idempotency keys are your safety net. Every external event (webhook from Stripe, callback from a bank) gets an idempotency key derived from the external reference. Duplicate deliveries are silently ignored, not double-posted.
- Build a reconciliation report from day one. Don't wait until you have a discrepancy. Run a daily job that compares your ledger totals against your payment processor's settlement reports. The first time it catches a mismatch, you'll be glad you built it early.
- Log the full context in metadata. The
metadataJSONB column on journal entries is your best friend during incidents. Store the request ID, the user agent, the IP, the processor's transaction ID. When you're debugging at 2 AM, you'll thank yourself.
References
- Martin Fowler — Accounting Patterns
- PostgreSQL Documentation — Explicit Locking (Advisory Locks)
- Square Payments API — Overview and Ledger Concepts
- Go database/sql Package Documentation
Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.