Put the invariant in the transaction
Read available funds, reserve or debit them, and write ledger entries in one database transaction. Use a row lock, a conditional update, or serializable isolation based on the workload. PostgreSQL documents the anomalies each isolation level allows.
Retry the full decision
If the database rejects a serializable transaction, restart the entire decision from a fresh read. PostgreSQL says the retry must include all logic that decides which queries to run and what values to use. Do not only repeat the final update.
Define available balance precisely
Available balance is posted credits minus posted debits minus active reservations, under the product rule. A pending withdrawal may reserve funds before the provider finishes. A failed provider result releases that reservation once; a successful result converts it into a posted debit. Test a reservation expiry against a late provider success, since a timed release followed by success can overdraw the wallet. Use a stable operation ID to join reservation, provider intent, and final journal. The customer display should show pending money separately from money available to spend.
Lock or isolation choice
A row lock is simple when one wallet row controls available funds. Serializable transactions can protect wider rules that span rows, but they can abort under contention and need full retries. A conditional update can be fast for one balance row. Choose from measured contention and the ledger invariant, then verify that every code path uses the same method.
Run a race without blocking a correct lock
PAY-11 in the test pack starts at 10,000 minor units, with no holds, and two distinct withdrawal IDs each requesting 7,000. Start two database connections together. Exactly one withdrawal can be funded, leaving 3,000 available. A fee-free ₦100/₦80 example uses the same rule, but has different numbers; keep one fixture unchanged for each run.
Place the start barrier before a locking read. With a row lock, worker B must wait while A checks and reserves funds, then read the new available amount. A barrier that waits for both workers after each has taken the same row lock will deadlock the test itself. For an optimistic version check, use a test hook after the unprotected read to force both workers to read the same version; one write must lose and restart the decision. For serializable isolation, retry the whole transaction after a serialization failure.
Reset the fixture for each run and record accepted operation, lock wait or retry, hold, journal, final available amount, and provider intent. Before provider success, expect one 7,000 hold and 3,000 available. After success, consume that hold and post one 7,000 debit, still leaving 3,000. Do not subtract both hold and debit. Crash after committing the hold and durable transfer intent, then restart the worker. It must resume the same intent without another reservation. A provider timeout keeps the hold until the original transfer is resolved.
Evidence to retain
Keep all request IDs, transaction outcomes, ledger entries, and final available balance from one real parallel run.
Sources
Put this into practice
Run the parallel withdrawal test against the database your product uses. Fix the shared balance operation, then rerun with holds and reversals. See our reconciliation race conditions and payment gateway testing service. To check a live flow, request a security review.