Making Wallet Mutations Safe Under Concurrency
Why checking a balance and then updating it is not enough in a financial system. A wallet can be represented by one database column. A safe wallet cannot. Consider a user with a balance of 100. Two withdrawal requests
Why checking a balance and then updating it is not enough in a financial system.
A wallet can be represented by one database column. A safe wallet cannot.
Consider a user with a balance of 100. Two withdrawal requests for 80 arrive almost simultaneously. Each request reads the balance before the other commits. Both see 100, both pass validation, and both subtract 80.
The application performed the expected check twice, yet it still spent the same money twice.
This is the central concurrency problem in wallet systems: validation and mutation must be one indivisible operation against a locked financial record.
Put the invariant inside the transaction
The basic invariant is straightforward:
A debit may commit only when the locked balance is greater than or equal to the debit amount.
A sanitized implementation looks like this:
with database_transaction():
wallet = Wallet.objects.select_for_update().get(
user_id=user_id,
currency=currency,
)
if wallet.balance < amount:
raise InsufficientFunds()
before = wallet.balance
wallet.balance -= amount
wallet.save()
LedgerEntry.objects.create(
wallet=wallet,
reference=reference,
direction="debit",
amount=amount,
balance_before=before,
balance_after=wallet.balance,
)
The row lock serializes competing mutations for the same wallet. Validation uses the balance that is current while the lock is held, rather than a stale value read earlier in the request.
Make the mutation idempotent
Concurrency safety prevents two different operations from overspending the wallet. Idempotency prevents one operation from being applied twice.
A deterministic mutation key can be derived from stable business data:
raw = f"{user_id}:{currency}:{direction}:{transaction_type}:{reference}"
mutation_key = sha256(raw.encode()).hexdigest()
The ledger should enforce uniqueness on that key. An application-level existence check is useful for a clear response, but the unique database constraint is the final protection against racing workers.
with database_transaction():
if LedgerEntry.objects.filter(idempotency_key=mutation_key).exists():
return "already applied"
wallet = lock_wallet(user_id, currency)
apply_mutation(wallet, amount)
create_ledger_entry(idempotency_key=mutation_key)
If two workers pass the existence check at the same time, the uniqueness constraint still prevents both ledger rows from being committed.
Keep balance history with the mutation
A wallet table answers βwhat is the balance now?β It does not explain how the balance arrived there.
Every mutation should record:
- the business reference;
- direction and transaction type;
- currency and amount;
- balance before;
- balance after;
- idempotency key;
- timestamp; and
- relevant sanitized metadata.
This creates an audit trail that supports customer support, reconciliation, incident investigation, and financial reporting.
The balance and ledger entry should commit in the same database transaction. A balance change without an audit entry is difficult to explain. An audit entry without the corresponding balance change is equally dangerous.
Treat each currency as a separate wallet
Multi-currency systems become easier to reason about when each user-currency pair has one authoritative wallet record.
UniqueConstraint(fields=["user", "currency"])
Conversions should be recorded as linked financial movements rather than an unexplained overwrite:
- debit the source wallet;
- record the source amount;
- record the applied exchange rate;
- credit the destination wallet;
- record the destination amount;
- preserve one conversion reference across both effects.
The conversion rate is part of the transactionβs history. Looking up todayβs rate later cannot reconstruct what the customer received at execution time.
Model holds separately from completed debits
Some operations need to reserve money before completion. A withdrawal may be awaiting an external provider or manual review. Immediately treating the reservation as a final debit makes failure recovery harder.
A clearer model distinguishes:
- total or actual balance;
- held balance;
- available balance; and
- completed ledger balance.
Available funds can then be expressed as:
available = balance - active_holds
On success, the hold becomes a committed debit. On failure, the hold is released. Both transitions should be idempotent.
Do not hide negative states with arithmetic tricks
Operational adjustments, chargebacks, and debt recovery introduce another challenge. A system may need to represent an amount owed without presenting an impossible or misleading wallet state.
The safest approach is to model debt, holds, and display policy explicitly. Mixing them into a single balance field makes future credits, support investigations, and reconciliation much harder.
The design principle
A financial mutation is not an assignment such as balance = balance - amount. It is a domain operation with concurrency rules, an immutable identity, an audit record, and explicit failure behavior.
The database is not merely storage in this design. Its transaction boundaries, row locks, and constraints are part of the payment systemβs correctness model.
The value of this design is structural: concurrent requests cannot safely bypass the locked balance check, and a repeated business operation cannot silently create a second ledger mutation. Correctness comes from enforceable database guarantees rather than assumptions about how often a request will be retried.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.