System design interview guide
Banking System Design Interview: Double-Entry Ledger, Holds, Transfers and Sharding
Short answer
A banking system is built on a double-entry ledger, the bank's book of every money movement. Each movement is a journal row with at least two entries, a debit and a credit, that sum to zero. A balance is the sum of an account's entries. A transfer inside the bank is one database transaction. Lock both balance rows in account-id order, check the available balance (the ledger balance minus holds, which are amounts already promised to a shop), write both entries and an event, and commit. Entries are never edited; mistakes are fixed with new entries that cancel them. Transfers to other banks go through a suspense account, an internal account for money in transit, and a clearing network that carries payments between banks, such as NEFT, IMPS or RTGS. Accounts are split across several database servers by account id, and every commit is copied to a second server before it is confirmed, so no finished transfer is lost.
Two courses by the author of this page:
770 lessons · 18 free to read
₹499 in India · $49 elsewhere, once
Get System DesignYou own this course
210 lessons · 10 free to read
₹999 in India · $49 elsewhere, once
Get AI EngineeringYou own this course
Save your progress and get the interview checklist PDF
A bank holds one number for every customer: how much money is in the account. Millions of people change those numbers every day. Asha sends Rs 500 to Ravi. Her card is used at a shop. Her salary arrives. Interest is worked out every night. Every change must be exact. It must never be lost. And it must be explainable years later to an auditor, a person whose job is to check the bank's books against the rules. Everything hard in this design comes from that rule: the number can never be wrong, and many changes hit it at once.
A banking system keeps every account in a double-entry ledger. A ledger is the bank's book of every money movement. Double-entry means each movement is written as at least two entries: a debit on one account and a credit on another. For a customer's account, a debit takes money out and a credit puts money in. The entries of each movement add up to zero, so money never appears or vanishes. It only moves. The ledger is the source of truth: a balance is the sum of an account's entries. For speed, a balance row sits next to the entries. It is updated in the same database transaction, a group of changes the database applies all together or not at all. Each account shows two balances. The ledger balance counts only posted, meaning final, entries. The available balance also subtracts holds. A hold reserves money for a payment that was approved but not yet collected, such as a card payment. A transfer inside the bank is one transaction. First, lock both balance rows, lowest account id first, so two transfers never wait for each other forever. Second, check the available balance. Third, insert both entries. Fourth, update both balances. Fifth, write an event to an outbox table, a table of messages for other services. A transfer to another bank first moves the money into a suspense account, an internal account for money in transit. Then it goes out through a clearing network, the system that carries payments between banks, such as NEFT, IMPS or RTGS. It completes or comes back later. Entries are never changed or deleted. A mistake is fixed with a reversing entry, a new entry that cancels the old one. The write path is strongly consistent: every balance check that decides whether money can move reads the latest committed (made final) balance on the primary database, the one server that takes writes. Accounts are sharded, meaning split across database servers, by account id. Every commit is copied to a standby server before the customer is told it worked, so a crash loses no finished transfer.
Where it shows up
This question comes up in backend and platform rounds at banks, fintech companies (technology companies that offer financial services), payment companies, brokerages (firms that buy and sell shares for customers) and wallet apps. It is also a general system design prompt, worded as "design a banking system", "design a ledger" or "design a money transfer service". Indian candidates meet it often, because many Indian backend roles are at banks or at companies that connect to them through NEFT, IMPS, RTGS and UPI.
Spec sheeta Banking System
- 01Accounts
- 50 million
- 02Money movements per day
- 50 million transactions
- 03Transactions per second
- about 580 average, about 5,800 at peak
- 04Ledger entries per year
- about 36.5 billion rows, about 7 TB
worked through below, with the maths
Why this question is asked
The question looks simple: store a balance, add and subtract. Many candidates stop there. They keep one balance column per account and run UPDATE balance = balance - 500. A strong answer brings in a double-entry ledger in the first few minutes and says why: a single column cannot say where money went, and an auditor cannot check it. The interviewer then pushes on a fixed set of points. Why do you need two balances, and what is a hold? How do you move money between two accounts so that a crash halfway never leaves one side done? What happens when two transfers touch the same two accounts at once? How do you fix an entry you can never edit? How do you shard a ledger when one transfer touches two servers? What about an account every transfer touches? And how much data may you lose in a disaster? The only right answer is none. Each point has a clear right answer and several wrong ones that sound fine. So the question separates people who have built money systems from people who have only used them.
This page is the free part.
The course goes deeper on the Banking System design
₹499 in India$49 everywhere elseonce, for the whole course
The System Design course covers the Banking System design across a run of lessons, not one page. These 4 alone are about 96 minutes of step-by-step reading, every one with a quiz.
- ACID Properties
Database transaction guarantees that keep your data correct.
- Idempotency
Why some operations are safe to retry and others charge you twice, and how to make everything safe.
- Idempotency Keys
Safely retry failed API requests without causing duplicate side effects.
- Serializability
Transactions execute as if they ran one at a time, the gold standard of isolation.
all part of
System Design Masterclass
770 lessons · about 283 hours · 18 free to read
₹499in India, by UPI
$49everywhere else, by PayPal
The concepts were explained in a clear and structured way, with practical examples that made even complex system design topics easier to understand. I especially liked the focus on real-world architecture, scalability, trade-offs. The content was well designed, engaging, and highly useful for anyone looking to strengthen their system design skills. Highly recommended for software engineers preparing for system design interviews or wanting to build a stronger foundation in designing scalable systems.
You own this course
Continue with ACID Propertiespay once,
yours for life
The payment system design series
Every payment system rests on the same few ideas: an idempotency key so a retry never charges twice, a double-entry ledger so no money appears or vanishes, and daily reconciliation against the bank. These guides show those ideas inside real products.
- Design a payment systemStart here: the core pattern every guide below uses
- Design StripeCard payments API, idempotency keys, webhooks
- Design RazorpayPayment gateway for India: UPI, cards, settlement
- Design UPIIndia's central payment switch run by NPCI
- Design PayPalWallet, ledger and sagas across services
- Design PaytmUPI app and wallet, the DEEMED state
- Design PhonePeUPI at national scale, sharded MySQL
- Design a digital walletBalances, holds and captures
- Design a banking system(this page)Core banking: ledger, available balance, NEFT/IMPS/RTGS
- Design CREDBill payments driven by order state machines
- Design fraud detectionScoring a payment in milliseconds
Live from the course bench
Before you design Banking, try the questions it rests on.
Real interview questions, live diagrams and measured lab results, pulled at random from the lessons. A new set every time you come back.
Requirements
Always clarify these in the first 5 minutes of the interview. Do not start drawing boxes until both lists are agreed.
Functional requirements
- Open and close customer accounts. A savings account is for keeping money and earns interest; a current account is for frequent business payments. Each account has one currency and a status
- Show each account's ledger balance and available balance, and a full transaction history
- Transfer money between two accounts in the same bank, so both sides happen or neither does
- Transfer money to other banks through NEFT (National Electronic Funds Transfer), IMPS (Immediate Payment Service) and RTGS (Real Time Gross Settlement), track each transfer until it settles (the money has finally moved between the banks), and handle returns
- Place, capture and release holds, for example a card payment that the shop collects a few days later
- Check limits and run fraud checks before any money moves
- Accrue interest (record interest as it is earned, before it is paid) and charge fees in an end-of-day batch, and produce monthly statements
- Correct mistakes with reversing entries, never by editing or deleting history
- Make every money-moving request safe to retry: the same request sent twice moves money once
Non-functional requirements
- Correctness first: the entries of every transaction sum to zero, and no balance ever disagrees with its entries
- Strong consistency where money is decided: a transfer that has returned success is seen by every later balance check and every balance read from the primary. History screens may be a few seconds behind
- Zero data loss: the recovery point objective (RPO, how much recent data you may lose in a disaster) is 0
- An immutable audit trail: a record of every entry, with who, what, when and why, that is never changed and is kept for many years
- High availability: customers send money at any hour, and NEFT, IMPS and RTGS all run around the clock
- Low latency (the time a request takes): an internal transfer commits in a few hundred milliseconds; a balance read takes tens of milliseconds
- One very busy account (a merchant, the bank's own fee account) must not slow down transfers for everyone else
Back-of-envelope scale estimates
Show your math. Pulling numbers from thin air signals you have not thought about the load.
Accounts
50 million
Assume a large retail bank with 50 million customer accounts. This is an interview assumption, not any real bank's number. Say it out loud before you use it. Add a few thousand internal accounts that belong to the bank itself: fee income, interest expense, interest payable, suspense accounts for money in transit, and settlement accounts for each clearing network.
Money movements per day
50 million transactions
Assume one money movement per account per day on average: card payments, transfers, salary credits, bill payments, ATM withdrawals. 50,000,000 x 1 = 50 million transactions a day.
Transactions per second
about 580 average, about 5,800 at peak
50,000,000 / 86,400 seconds = about 579, so about 580 a second on average. Assume peaks of 10 times the average, for example on salary day or during a festival sale: about 5,800 a second. Each transfer writes 7 rows: 1 idempotency key, 1 journal row, 2 entries, 2 balance updates and 1 outbox row. So the peak is 5,800 x 7 = about 40,000 row writes a second. That is too much to ask of one database server under strict locking, and it is the real reason to shard.
Ledger entries per year
about 36.5 billion rows, about 7 TB
2 entries per transaction: 50,000,000 x 2 = 100 million entries a day, and 100,000,000 x 365 = 36.5 billion entries a year. At about 200 bytes per entry including indexes, that is 36,500,000,000 x 200 = 7.3 trillion bytes, about 7 TB a year. Entries are never deleted, so this grows every year.
Balance reads per second
about 5,800 average, about 58,000 at peak
Assume 10 balance or history reads for every money movement. People open the app, check after paying, and every transfer reads balances first. 50,000,000 x 10 = 500 million reads a day. 500,000,000 / 86,400 = about 5,800 a second, and about 58,000 at a 10 times peak. History reads go to replicas, copies of the database that only serve reads, and to a statement store. The balance check inside a transfer always reads the primary, the one server that takes writes.
End-of-day interest batch
50 million accruals in about 2 hours, about 6,900 a second
If interest is worked out for every account every night, the batch writes one accrual per account: 50 million rows. In a 2-hour window that is 50,000,000 / 7,200 seconds = about 6,900 a second. Daytime transfers keep running during the batch, so the batch works in small chunks, ordered by account id, each chunk its own short transaction, so it never holds locks for long.
High-level architecture
Draw one write path and several read paths around a single ledger. The write path is every step that changes money; the read paths only look. The customer opens the mobile or net banking app. Every request goes through an API gateway, the single front door for all requests. It checks the login and applies rate limits, a cap on how many requests one customer or device may send per minute. A transfer request reaches the transfer service with an idempotency key. That is a random id the app makes once per transfer, so a retry is recognised and money never moves twice. The transfer service first asks the limits and fraud service whether this transfer is allowed. Then it opens one transaction on the ledger database. This is a relational database such as PostgreSQL: one that stores data in tables and supports transactions and locks. Inside that transaction the service does six things. It records the idempotency key. It locks the two balance rows in account-id order. It checks the available balance. It inserts one journal row and two ledger entries that sum to zero. It updates both balance rows. And it inserts an event, a short message that says "this transfer was posted", into the outbox table. Then it commits, which makes all of it final at once. Because the event is written in the same transaction as the money, there is never a transfer without its event, or an event without its transfer. A relay, a small program that copies new outbox rows onward, publishes each event to an event log such as Kafka, a system that stores events in order and lets many services read them. Statement and notification services read it too. The clearing gateway reads events to send payments to other banks, and sends inward credits and returns back through the transfer service, which stays the only writer. An end-of-day batch accrues interest and charges fees by posting ordinary ledger transactions. The ledger database is sharded by account id. Each shard has a synchronous standby: a second server that must save every commit before the customer is told it worked. A reconciliation job, a check that compares two records that should agree, runs every night. It checks that each balance row equals the sum of its entries and that every transaction sums to zero.
In a real interview, sketch this on the whiteboard before diving into any single box.
One transfer of Rs 500, inside the bank and to another bank
Asha (account 1001) sends Rs 500 to Ravi (account 2002) at the same bank. Then she sends Rs 500 to her landlord at a different bank. Amounts are stored as whole paise, so Rs 500 is 50,000.
- 1
Send with a key
Asha taps Send. The app creates one idempotency key for this transfer and sends POST /transfers with from 1001, to 2002, amount 50,000 paise and the key.
- 2
Check limits and fraud
The transfer service asks the limits and fraud service. Rs 500 is within Asha's daily limit, Ravi is a saved payee, and the fraud score is low. The answer is allow.
- 3
Record the key
The service opens a database transaction and inserts the key. If the key already exists, this is a retry. It rolls back and returns the stored reply, and no money moves twice.
- 4
Lock in order
It locks the balance rows of 1001 and 2002 with SELECT ... FOR UPDATE, a database command that reads rows and locks them. It locks the lowest account id first. Any other transfer touching either account now waits for this one.
- 5
Check the money
Asha's available balance is her ledger balance minus her holds, plus any overdraft limit (money the bank lets her borrow by going below zero). It is at least Rs 500, so the transfer can go ahead. If it were not, the service would roll back and reply "insufficient funds".
- 6
Post both legs
It inserts journal row T-901, an entry of -50,000 on 1001 and an entry of +50,000 on 2002. The entries sum to zero. It updates both balance rows, inserts a transfer.posted event into the outbox, and stores the reply next to the idempotency key. Then it commits, and both locks are released.
- 7
Tell everyone
The relay publishes the event to the event log. The statement service adds a line to both histories, and the notification service sends Asha and Ravi an SMS.
- 8
Another bank: debit into suspense
For the landlord, the same kind of transaction debits Asha 50,000 and credits the bank's NEFT outward suspense account 50,000. The transfer row is marked PENDING. Asha's balance drops at once, so she cannot spend the money twice.
- 9
Send through clearing
The clearing gateway reads the event and sends the payment in the next NEFT batch. NEFT settles in half-hourly batches, and the landlord's bank is expected to credit him within two hours of the batch settling.
- 10
Settle or return
When the batch settles, the bank posts the amount from the suspense account to its NEFT settlement account and marks the transfer COMPLETED. If the landlord's bank cannot credit him, it returns the money. The return comes back through the transfer service as a new credit to Asha. Nothing is ever edited.
Core components
Walk through each service. The interviewer wants to hear what each one owns, not just the names.
API gateway
The front door for the mobile app, net banking and partner systems. It checks the login session, applies rate limits per customer and per device, and passes requests on. It holds no money logic.
Transfer service
The only component allowed to post money movements, including inward credits and returns that arrive from other banks. In one database transaction it records the idempotency key, locks balance rows in account-id order, checks the available balance, writes the journal row and its entries, updates balances and writes the outbox event. It keeps no state, so many copies run behind a load balancer.
Limits and fraud service
Answers "is this transfer allowed?" before any money moves. Limits are rules the bank or the customer sets: a daily cap, a lower cap for a payee added in the last day, a cap per channel. Fraud checks score the transfer against the customer's normal pattern. It must answer within a strict time budget. If it is down, the bank falls back to simple rules rather than letting every transfer through.
Ledger database
A relational database such as PostgreSQL, because posting needs transactions, row locks and constraints, rules the database checks on every write. It holds accounts, journal rows, ledger entries, balance rows, holds, idempotency keys and the outbox. It is the source of truth for every balance. It is sharded by account id, and each shard has a synchronous standby.
Outbox relay and event log
The relay reads new rows from the outbox table, publishes them to an event log such as Kafka, then marks them sent. Because the event is written in the posting transaction, an event is never lost and never sent for a transfer that rolled back. A consumer may see the same event twice, so each one remembers the event ids it has already applied.
Statement and history service
A read model: a copy of the data shaped for one kind of question, here "show me my last 50 transactions" and "give me my September statement". It is built from the event log, so it can be a few seconds behind the ledger. It never decides whether money can move.
Clearing gateway
Talks to the clearing networks. NEFT and RTGS are owned and run by the Reserve Bank of India (RBI). IMPS is run by NPCI, the National Payments Corporation of India. The gateway sends outward payments, and receives inward payments, returns and settlement reports. Everything it receives goes back through the transfer service as an ordinary ledger posting.
End-of-day batch and reconciliation
Jobs that run on a schedule. They accrue interest, charge fees, close the day's books and build statements. They also run the reconciliation checks.
Data model
Pick the right store per table. Justify each choice with the access pattern, not by reflex.
accountsaccount_idcustomer_idtype (savings / current / internal)currencystatusoverdraft_limitopened_atOne row per account, including the bank's own internal accounts (fee income, interest expense, interest payable, NEFT outward suspense). Status is ACTIVE, FROZEN or CLOSED. Each account has exactly one currency, so one entry never mixes currencies.
transactions (journal)txn_idkind (INTERNAL / OUTWARD / INWARD / INTEREST / FEE / REVERSAL)statusreverses_txn_ididem_keycreated_bycreated_atOne row per money movement. It groups the entries that belong together. A unique index on reverses_txn_id makes sure a transaction can be reversed only once. created_by records which service or staff member made it, for the audit trail.
ledger_entriesentry_idtxn_idaccount_idamount (signed, in paise)posted_atTwo or more rows per transaction. Credits are positive and debits negative, for every account. The bank's own asset and expense accounts then carry negative balances, and the screen flips the sign when it shows them. The amounts of one txn_id always sum to zero. Amounts are whole paise in a BIGINT, a 64-bit whole number, because floating point numbers cannot store values like 0.10 exactly. The application's database role (the login it uses, with its own permissions) may INSERT (add) and SELECT (read) rows but never UPDATE (change) or DELETE (remove) them. Indexed by (account_id, posted_at) for history.
account_balancesaccount_idledger_balanceheld_amountlast_entry_idupdated_atOne row per account. It is a cached sum of the entries, updated in the same transaction as every entry, so it is never behind. This is the row that SELECT ... FOR UPDATE locks during a transfer. available = ledger_balance - held_amount + overdraft_limit. A nightly job recomputes the sum of entries and compares.
holdshold_idaccount_idamountstatus (ACTIVE / CAPTURED / RELEASED / EXPIRED)referenceexpires_atMoney reserved for a payment that was approved but not yet collected, such as a card payment. Placing a hold adds to held_amount. Capturing it posts a real transaction and closes the hold. Releasing or expiring it only closes the hold. Every status change is a guarded update: an UPDATE with WHERE status = 'ACTIVE', so it only succeeds if nobody changed the hold first.
idempotency_keyscustomer_ididem_keyrequest_hashtxn_idresponsecreated_atPrimary key (customer_id, idem_key). Written in the posting transaction, together with the reply, so a key never exists without its transfer and a retry always finds a stored answer. request_hash catches a reused key sent with a different amount, which is an error.
outboxevent_idtxn_idtypepayloadcreated_atpublished_atEvents written in the posting transaction (transfer.posted, transfer.returned, hold.placed). The relay publishes them and sets published_at.
external_transferstxn_idnetwork (NEFT / IMPS / RTGS)beneficiary_ifscbeneficiary_accountnetwork_refstatus (PENDING / SENT / UNKNOWN / COMPLETED / RETURNED)batch_idOne row per transfer to another bank, tracking it through the network. IFSC is the code that identifies the other bank's branch. Status only moves forward, with guarded updates.
Deep dives
These are the conversations the interviewer is steering you toward. Practice each one until you can talk through it without notes.
The double-entry ledger: two entries, sum zero
Start with the design to reject: one balance column per account, changed in place. The payment system guide on this site explains in full why that fails. For a bank there is one more reason. To the bank, a customer's deposit is a liability: money the bank owes someone. An auditor checks those liabilities line by line, so every rupee in every balance must be explained by a record. Double-entry bookkeeping gives that. Stripe's engineering team calls it a standard method for making sure all money in a system is accounted for. Every entry is a debit or a credit. For a customer's account, which is money the bank owes, a debit takes money out and a credit puts money in. That is why a salary arriving is a credit to your account. For the bank's own asset and expense accounts it is the other way round: a debit makes them grow. Accountants call these two kinds credit-normal accounts (they grow with a credit, like customer deposits) and debit-normal accounts (they grow with a debit, like cash or interest expense). Every money movement is written as at least two entries, and the entries of one movement always sum to zero. In software, store each entry as a row with a signed amount. Credits are positive and debits negative, for every account. The bank's own asset and expense accounts then carry negative balances, and the screen flips the sign when it shows them. A transfer of Rs 500 is then one journal row and two entries: -50,000 paise on Asha and +50,000 paise on Ravi. The sum is zero. A balance is the sum of an account's entries, so a balance can always be explained line by line. Store amounts as whole paise in a 64-bit integer, never as floating point, because floating point cannot store 0.10 exactly and small errors add up over billions of rows. Check the zero-sum rule in the database itself, not only in the code. A deferred constraint trigger is a check the database runs at commit time, after all the rows of the transaction are in. Use one that sums the entries of each transaction and refuses the commit if the sum is not zero. Then even a bug cannot create money.

Two balances: ledger balance, available balance and holds
Every bank app shows two numbers, and a good answer explains why. Money takes time to finish moving. When Asha pays Rs 2,000 with her debit card, the shop's card machine asks the bank to approve the payment. The bank says yes, but the shop collects the money later, often days later. In between, the Rs 2,000 is promised but not taken. If the bank only looked at the ledger balance of Rs 10,000, Asha could send Rs 9,000 to Ravi, and then the shop's Rs 2,000 would arrive with only Rs 1,000 left. So the bank places a hold: a record that reserves part of the balance for one approved payment. The ledger balance is the sum of posted entries: Rs 10,000. The available balance is the ledger balance minus the holds, plus any overdraft limit: Rs 8,000 here. Every new spending decision is checked against the available balance. Modern Treasury describes the same idea for any ledger: the available balance counts outgoing pending amounts as already gone and incoming pending amounts as not yet arrived, because that is the safe way to approve a new transaction. TigerBeetle, a database built only for ledgers, has this built in: a pending transfer reserves the amount, and a later transfer posts it (makes it final) or voids it (cancels it), or it expires. A hold has three endings. Capture: the shop collects, and the bank posts a real transaction (debit Asha, credit the card settlement account) and closes the hold in the same database transaction, so the available balance does not jump. Release: the shop cancels, the hold closes and the money is free again. Expire: nothing happens before the expiry time, and a job closes the hold. Each ending is a guarded update, WHERE status = 'ACTIVE', so a capture and an expiry that arrive together cannot both win. A capture can be for a different amount than the hold, for example a tip added at a restaurant, and it can arrive after the hold has expired. The bank has already promised the card network to pay, so the capture path posts anyway, against the ledger, without a balance check. If that pushes the account below zero, the shortfall goes to the bank's collections process instead of being refused. Keep held_amount on the balance row and change it in the same transaction as the hold. Then placing a hold and spending the money are protected by the same row lock.
BEGIN;
SELECT ledger_balance, held_amount
FROM account_balances WHERE account_id = 1001
FOR UPDATE; -- 1,000,000 posted, 0 held
-- app checks: ledger_balance - held_amount + overdraft_limit >= 200000
-- (overdraft is 0 here: 1,000,000 - 0 + 0 >= 200,000)
INSERT INTO holds (hold_id, account_id, amount, status, expires_at)
VALUES ('H-77', 1001, 200000, 'ACTIVE', now() + interval '7 days');
UPDATE account_balances
SET held_amount = held_amount + 200000
WHERE account_id = 1001;
COMMIT;
SELECT ledger_balance - held_amount + a.overdraft_limit AS available
FROM account_balances b JOIN accounts a USING (account_id)
WHERE account_id = 1001; -- 800,000 paise = Rs 8,000
An internal transfer: one transaction, locks in account order
Asha to Ravi, same bank, is the case to explain most carefully. Atomicity means the transfer happens completely or not at all. It is the A in ACID, the four promises a database transaction makes. So the whole transfer is one database transaction. Inside it, the hard part is two transfers that touch the same accounts at once. Suppose Asha sends Rs 500 to Ravi while Ravi sends Rs 300 to Asha. The first transfer locks Asha's row and then wants Ravi's. The second locks Ravi's row and then wants Asha's. Each waits for the other, forever. This is a deadlock. PostgreSQL finds deadlocks and aborts one of the transactions, but which one it picks is hard to predict, and the aborted transfer must be retried. Its documentation gives this very example, two accounts updated in opposite orders. It also gives the fix: make every transaction take its locks in one consistent order. So always lock the lower account id first. Both transfers now want 1001 first. One gets it, and the other waits. Nobody waits in a circle. Use SELECT ... FOR UPDATE on the balance rows. In PostgreSQL, FOR UPDATE locks the selected rows, so any other transaction that tries to update or lock them waits until this transaction ends. With both rows locked, the service reads the available balance, and nobody can change it between the check and the write. That gap is where a classic bug lives. The balance is Rs 1,000. Two withdrawals of Rs 1,000 each read it at the same time. Each sees enough money, and both succeed. Keep the transaction short: run the fraud check before BEGIN, and send events through the outbox. There is another way to stop the double spend. The isolation level of a database is the rule for what one transaction can see of others running at the same time. The strictest level, Serializable, prevents every anomaly, meaning every wrong result that comes from transactions overlapping. But PostgreSQL then aborts one of two clashing transactions with a serialization error, and every caller must retry. Explicit row locks in a fixed order give the same safety with fewer surprises, because here the rows a transfer touches are known in advance.
BEGIN;
INSERT INTO idempotency_keys (customer_id, idem_key, request_hash)
VALUES (77, 'k-7f3a', 'sha256:91c2...'); -- conflict = retry, stop
SELECT account_id, ledger_balance, held_amount
FROM account_balances
WHERE account_id IN (1001, 2002)
ORDER BY account_id -- lowest id first, always
FOR UPDATE;
-- app checks Asha: ledger_balance - held_amount + overdraft_limit >= 50000
-- (overdraft is 0 here)
INSERT INTO transactions (txn_id, kind, idem_key) VALUES ('T-901', 'INTERNAL', 'k-7f3a');
INSERT INTO ledger_entries (txn_id, account_id, amount)
VALUES ('T-901', 1001, -50000), ('T-901', 2002, 50000); -- sums to 0
UPDATE account_balances SET ledger_balance = ledger_balance - 50000 WHERE account_id = 1001;
UPDATE account_balances SET ledger_balance = ledger_balance + 50000 WHERE account_id = 2002;
INSERT INTO outbox (txn_id, type) VALUES ('T-901', 'transfer.posted');
UPDATE idempotency_keys SET txn_id = 'T-901', response = '{"status":"POSTED"}'
WHERE customer_id = 77 AND idem_key = 'k-7f3a'; -- a retry gets this reply
COMMIT;Never update, never delete: corrections are reversing entries
A bank's history must stay exactly as it happened. An auditor, a regulator or a court may ask what an account looked like on 3 March three years ago. The answer must not depend on whether someone edited a row since. So ledger entries are immutable: once written, never changed and never deleted. Enforce this in the database, not only by habit. The database role the application uses gets INSERT and SELECT on ledger_entries and nothing else. Then even a bug or a careless script cannot run UPDATE or DELETE. TigerBeetle's documentation makes the same rule: accounts and transfers are immutable once created. Mistakes still happen. A transfer was posted twice, a fee was charged by error, an amount was keyed wrong. The fix is a new transaction that reverses the old one: copy each entry of the wrong transaction with the opposite sign. The two transactions together sum to zero for every account, so the balance returns to where it was. The history shows the mistake, the fix, who made the fix and why. Martin Fowler's accounting patterns call this a reversal adjustment: for each wrong entry you post an opposite entry that cancels it. Then post the correct transaction as a new one, if there was a correct amount. Three details matter. First, a transaction may be reversed only once, so put a unique index on reverses_txn_id. Second, a reversal runs through the same path as any transfer, so the statement shows it and an SMS can say so. Third, a reversal may not always be possible. If Ravi has already spent the Rs 500 that reached him by mistake, the reversal would push his account below zero. That is a business decision, not a database one. The bank may freeze the amount, ask the customer, or move the loss to an internal account. It never edits the original entry.
BEGIN;
INSERT INTO transactions (txn_id, kind, reverses_txn_id, reason, created_by)
VALUES ('T-955', 'REVERSAL', 'T-901', 'posted twice', 'ops:priya');
-- unique index on reverses_txn_id: a second reversal of T-901 fails here
INSERT INTO ledger_entries (txn_id, account_id, amount)
SELECT 'T-955', account_id, -amount
FROM ledger_entries
WHERE txn_id = 'T-901'; -- +50000 on 1001, -50000 on 2002
-- then lock both balance rows in account-id order and apply the same
-- amounts to ledger_balance, and write a transfer.reversed outbox event
COMMIT;Transfers to other banks: pending, settlement and returns
When Ravi's account is at another bank, your database cannot write to his account. The money has to leave your books and arrive in theirs, through a clearing network. In India the main ones are NEFT and RTGS, owned and run by the Reserve Bank of India, and IMPS, run by NPCI. They behave differently, and that changes your design. NEFT works in batches. The RBI says it runs in half-hourly batches, around the clock, every day of the year. The receiving bank should credit the customer within two hours of the batch settling. If it cannot, it must return the money to the sending bank within two hours of the batch completing. RTGS settles each payment one by one, in real time. It uses no netting, which means payments are not added up and offset against each other first. Its minimum is Rs 2 lakh, and there is no upper limit. The receiving bank must credit the customer within 30 minutes. A payment that cannot be credited goes back within one hour, or before the end of the RTGS business day if that comes sooner. The RBI describes IMPS as a 24x7 instant domestic funds transfer service, run by NPCI. Design the outward transfer as a small state machine on top of the ledger. A state machine lists every state a transfer can be in and the only moves allowed between them: PENDING, then SENT, then COMPLETED or RETURNED. Step one is local and immediate. In one transaction, debit Asha, credit the NEFT outward suspense account, and mark the transfer PENDING. Asha's balance drops at once, so she cannot spend the money twice. Step two: the clearing gateway sends the payment in the next batch and marks it SENT. Step three comes from the network's settlement report. The bank posts the amount from the suspense account to its settlement account for that network, and marks the transfer COMPLETED. If a return arrives instead, the bank posts a new transaction back to Asha, marks the transfer RETURNED, and sends her an SMS. IMPS adds two twists. First, IMPS answers in seconds with success, failure or no answer. No answer means unknown, not failed. Keep the money in suspense, ask the network for the status, and only then complete or return it. Second, IMPS credits the customer instantly, but the banks settle with each other later, in cycles; the RBI's 2021 statement mentions these IMPS settlement cycles. So an outward IMPS also sits in a suspense account until its settlement cycle. Each step is a short transaction that is safe to repeat. Once everything in transit has settled, the suspense account should be back at zero, so a nightly check that it matches the transfers still in flight finds lost messages fast. Inward transfers are the mirror image: the network's message becomes a credit to the customer and a debit to the network's settlement account, posted by the transfer service.
Interest, fees and the end-of-day batch
Not every money movement starts with a customer. Interest, fees, standing instructions and statements come from scheduled jobs. This is batch processing: work collected and run in large groups on a timetable, instead of one request at a time. This page assumes interest on a savings account is worked out on each day's closing balance and paid every quarter. The real rule comes from the account's terms and the regulator, so say "assume" in the interview. The design has two parts. Every night, for every account, the job computes that day's interest from the closing balance and adds it to an accrual record per account. Accrual means interest that has been earned but not yet paid. The ledger gets one summary transaction for the night: debit interest expense, credit interest payable, an internal account for interest the bank owes but has not paid yet. On payout day, one transaction per account moves the money: credit the customer, debit interest payable. The batch must be safe to rerun. If it crashes at account 31 million, running it again must not pay the first 31 million twice. Give each posting a natural idempotency key, such as interest:account_id:2026-Q3, and insert it with the posting, so a rerun finds the key and skips. Process accounts in order of account id, in chunks of a few thousand, each chunk its own short transaction. Save the last account id done, so a restart picks up there. Short chunks matter because daytime transfers keep running. NEFT, IMPS and RTGS run around the clock, so there is no quiet night in which the bank can pause all transfers. The "closing balance" must therefore be defined by a cut-off time, not by when the job happens to read the row. Use the entries posted before midnight (posted_at earlier than 00:00:00 of the next day), not the live balance row, and a transfer at 00:00:05 belongs to the next day. The same batch closes the day. It checks that every transaction sums to zero, that each balance row equals the sum of its entries, and that suspense accounts match the transfers still in flight.
Sharding accounts and the cross-shard transfer
At the peak in the estimates, about 40,000 row writes a second under strict locks, one database server is a risk. Sharding splits the data across several database servers, and each server is called a shard. A shard key decides where each row lives. For a bank, the shard key is account_id. Each account's balance row, entries, holds and idempotency keys live on one shard. Queries for one account, the most common kind, touch one server. The trouble starts with a transfer: Asha may live on shard 1 and Ravi on shard 4, and one ordinary database transaction cannot span two servers. There are two ways out. The first is two-phase commit. A coordinator, one process in charge of the transfer, asks both shards to prepare. Both promise they can commit. Then the coordinator tells both to commit. This keeps the transfer atomic, but it has two costs. If the coordinator crashes after prepare, both shards keep their locks until it comes back. And every cross-shard transfer holds its locks across two network round trips. The second way, more common in ledger designs, splits the transfer into two local transactions joined by an internal account, the in-flight account. On shard 1, in one transaction: debit Asha, credit shard 1's in-flight account, write an outbox event. On shard 4, triggered by that event: debit shard 4's in-flight account, credit Ravi. Each local transaction sums to zero on its own shard. Between the two steps the money sits visibly in the in-flight accounts, never lost. This is a saga, a chain of local steps where each has an undo step. If Ravi's account turns out to be closed, shard 4 rejects it, and shard 1 posts a reversing transaction back to Asha. Both steps carry the transfer id as an idempotency key, so a repeated event never credits Ravi twice. A nightly check that the in-flight accounts sum to zero catches stuck transfers. Make this case rarer by keeping all accounts of one customer on one shard: shard by customer id, so moving money between your own savings and current accounts stays local.
Hot accounts: the merchant and the bank's own accounts
Sharding spreads accounts apart. It does nothing for one account that every transfer touches. A hot account is one that a large share of transfers write to: a big merchant's collection account during a sale, a government tax account on a due date, or the bank's own fee income account, which receives a credit with every fee charged. Every posting to an account must lock that account's balance row. A row lock is held until commit, and with a synchronous standby the commit waits for the standby too. If one posting holds the lock for about 2 milliseconds, including that wait, one row can take at most 1,000 / 2 = 500 postings a second, however many shards you add. At a peak of 5,800 transactions a second, a fee account touched by one in four of them would need 1,450 a second, nearly three times what one row can do. Three fixes, in order of how often they are used. First, an account that only receives money never needs a balance check. The check exists to stop spending money that is not there, and nobody spends from the fee income account in real time. So postings to it can insert the entry without locking a balance row, and a job folds new entries into the balance every few seconds. The entry is still written in the same transaction as the customer's side, so the transaction still sums to zero. Second, split the hot account into sub-accounts. The merchant's collection account becomes 16 sub-accounts, a posting picks one by hashing the transfer id, and the merchant's balance is the sum of the 16. Each row now takes one sixteenth of the load. Third, for accounts that do need a balance check and are very busy, batch the postings: queue requests for a few milliseconds and post them together in one transaction with one lock. The same trap hides in the batch. If 50 million interest payouts each lock the bank's interest payable row, at 500 a second that is about 100,000 seconds, more than a day. The bank's internal accounts never need a balance check, so post to them without locking their balance row and fold their entries into the balance afterwards, or post one total per chunk. Name the hot account before the interviewer does.

Trade-offs to discuss
Every senior interviewer expects you to surface at least 3 of these. Pick the decisions, state the alternatives, and justify your choice.
Double-entry ledger vs a single balance column
The payment system guide covers the general case. The bank-specific reason is that deposits are liabilities, money the bank owes its customers, and an auditor checks them line by line. A balance column cannot show the lines. Keep the ledger as the source of truth, with a balance row beside it as a cache updated in the same transaction.
Strong consistency vs eventual consistency on the write path
Eventual consistency, where different readers may see different values for a while, is cheaper and more available. For a balance check it is wrong: two withdrawals can each see the old balance and both succeed. Every balance check that decides whether money can move reads the latest committed balance under a lock on the primary. Statements, notifications and analytics can be eventually consistent, because they never decide whether money can move.
Explicit row locks in account order vs Serializable isolation
Serializable isolation in PostgreSQL prevents every anomaly without thinking about locks, but it aborts clashing transactions with serialization errors that every caller must retry. Explicit SELECT ... FOR UPDATE in account-id order makes the second transfer wait instead of fail. It cannot deadlock as long as every code path that locks balance rows (transfers, captures, reversals, inward credits, interest and fees) locks them in the same account-id order. For transfers, where the rows are known up front, explicit locks are the default here.
Two-phase commit vs a saga through an in-flight account for cross-shard transfers
Two-phase commit keeps a transfer atomic across shards, but locks are held across network round trips, and a crashed coordinator can leave rows locked. A saga posts two local transactions through an in-flight account. No locks are held across servers, money in transit is visible, and a failure is undone with a reversal. The cost is a short window where Asha has been debited and Ravi not yet credited. Most ledger designs accept that window.
Synchronous vs asynchronous replication
Every database first writes each change to its write-ahead log, a file on disk that records changes before they are applied. Asynchronous replication sends that log to the standby after the commit, which is faster. But if the primary dies, the last transfers it confirmed may never reach the standby, and money a customer was told had moved is gone. Synchronous replication waits until the standby has written the commit to its own log on disk before telling the customer. That gives an RPO of 0, at the cost of a few milliseconds per commit. For a ledger, that cost is worth it.
Free PDF · 18 pages
Get the free System Design Interview Cheat Sheet
The interview in seven stages, the numbers worth knowing by heart, and twelve classic systems on one page each, every line linked to the lesson it comes from.
Follow-up questions to expect
After the main design, the interviewer usually picks one part and asks more. Here is what to say.
The app times out after sending a transfer. Did the money move?
The app does not know, so it retries with the same idempotency key. The server inserts the key, and later the reply, in the same transaction as the transfer. If the first attempt committed, the key is already there and the server returns the stored reply without moving money again. If it rolled back, the key is not there and the transfer runs once now.
How do you prove the ledger is correct?
Run checks that do not trust the code that wrote the rows. Every transaction's entries must sum to zero. Each balance row must equal the sum of its account's entries. Suspense and in-flight accounts must match the transfers still in flight, and settlement accounts must match the networks' reports. Run them nightly, and alert on any difference, however small.
The primary database of one shard fails. What happens to transfers?
The synchronous standby already has every committed transfer in its write-ahead log, so it is promoted and nothing confirmed to a customer is lost. Transfers that were in progress and never committed are rolled back, and the apps retry them with their idempotency keys. Transfers on other shards carry on, apart from cross-shard ones, which wait at the in-flight account until the shard is back.
A customer disputes a transaction from two years ago. What do you show?
The original journal row and its entries, exactly as written, with created_by and created_at, plus any reversal that refers to it. Because entries are never edited or deleted, the balance on any past date can be rebuilt by summing the entries up to that date, and it will match what the statement showed at the time.
How a Banking System actually does it
Each mechanism on this page is documented by the people who build or run it. Martin Fowler's accounting patterns describe an account as a list of entries whose balance is their sum, and the reversal adjustment. Modern Treasury explains debit-normal and credit-normal accounts and defines posted, pending and available balance. TigerBeetle's documentation covers immutable transfers, a flag that stops a customer account going below zero, and holds as pending transfers. Stripe's Ledger post describes an immutable double-entry log of five billion events a day. PostgreSQL's documentation covers FOR UPDATE, the two-account deadlock, Serializable retries and synchronous replication. The Reserve Bank of India's FAQs give the NEFT and RTGS timings used above, and its 2021 policy statement describes IMPS and its settlement cycles.
Sources
- Martin Fowler: Patterns for Accounting (accounts, entries, reversal adjustment)
- Modern Treasury: Accounting for Developers, Part I: The Fundamentals
- Modern Treasury: How to Think About Ledger Balances (posted, pending, available)
- TigerBeetle documentation: Data Modeling (immutability, debits_must_not_exceed_credits)
- TigerBeetle documentation: Two-Phase Transfers (pending, post, void, timeout)
- Stripe engineering: Ledger, Stripe's system for tracking and validating money movement (2024)
- PostgreSQL documentation: Explicit Locking (FOR UPDATE, deadlocks, consistent lock order)
- PostgreSQL documentation: Transaction Isolation (Read Committed default, Serializable retries)
- PostgreSQL documentation: Log-Shipping Standby Servers (synchronous replication)
- Reserve Bank of India: NEFT frequently asked questions
- Reserve Bank of India: RTGS frequently asked questions
- Reserve Bank of India: Statement on Developmental and Regulatory Policies, 8 Oct 2021 (IMPS)
Lessons to study before this interview
If any of these topics are fuzzy, the interviewer will catch it. Each lesson is 15 to 60 minutes with diagrams, code, and a quiz.
ACID Properties
foundation / core fundamentals
Idempotency
foundation / core fundamentals
Idempotency Keys
intermediate / api design protocols
Serializability
advanced / consistency models
Snapshot Isolation
advanced / consistency models
Distributed Transactions
advanced / distributed systems core
Two-Phase Commit
advanced / distributed systems core
Saga Pattern
advanced / distributed systems core
Event Sourcing
advanced / distributed systems core
CQRS
advanced / distributed systems core
Transactional Outbox Pattern
intermediate / messaging event systems
Audit Logging
intermediate / data governance compliance
Batch Processing
advanced / stream batch processing
Database Sharding
foundation / database fundamentals
RPO
advanced / reliability resilience
Design a Payment System
capstone / capstone
Frequently asked questions
Go deeper
This guide is built on these 6 lessons. Each one teaches one piece of the Banking System design above, step by step.
- ACID PropertiesSystem Design · core fundamentals · 25 min
- IdempotencySystem Design · core fundamentals · 28 min
- Idempotency KeysSystem Design · api design protocols · 18 min
- SerializabilitySystem Design · consistency models · 25 min
- Snapshot IsolationSystem Design · consistency models · 25 min
- Distributed TransactionsSystem Design · distributed systems core · 42 min
Practice with 770 system design lessons
Lifetime access for ₹499 in India or $49 elsewhere. Interactive diagrams, quizzes, and 20 capstone projects to practise on.
Interview prep packs
This one question is not enough for the interview.
Our System Design interview pack is being written and is not on sale yet. Until it is out, the System Design course teaches this end to end, and these interview packs are on sale now, each question with a full answer and a diagram.
System Design and Distributed Systems: about 200 questions, coming soon. Not on sale yet.








