Transfer locking and final balance

Locks force concurrent transfers into a safe order, so the books always balance.

The idea

Without locking, two transfers that both read an account's balance can each write back a value computed from the same stale number — the second write silently overwrites the first. That is the lost update anomaly, and money simply vanishes from the ledger.

With row locks acquired in a fixed order, transfers that touch a shared account serialize: one runs to completion before the other reads. Each transfer is balanced (one debit, one matching credit), so the total across all accounts is a conserved invariant — it is the same before and after, no matter how the work interleaves.

Press play to watch two transfers run safely under locks.

How it works

Each transfer opens a transaction, then locks the rows it will touch in account-id order. Acquiring locks in the same global order on every code path is what stops two transfers from grabbing the same pair in opposite orders and deadlocking. Inside the lock it reads, checks funds, debits and credits, then commits and releases.

# Transfer money safely under row locks.
def transfer(conn, src, dst, amount):
    # Lock both accounts in a FIXED order (ascending id),
    # so concurrent transfers can never deadlock on the pair.
    first, second = sorted((src, dst))

    conn.execute("BEGIN")
    # SELECT ... FOR UPDATE takes a row lock and blocks
    # any other transfer touching the same account.
    lock_row(conn, first)
    lock_row(conn, second)

    bal_src = read_balance(conn, src)
    if bal_src < amount:
        conn.execute("ROLLBACK")        # check funds INSIDE the lock
        return "insufficient funds"

    write_balance(conn, src, bal_src - amount)          # debit
    write_balance(conn, dst, read_balance(conn, dst) + amount)  # credit

    conn.execute("COMMIT")              # releases both locks
    return "ok"

Trade-offs

PropertyDetail
IsolationRow locks serialize transfers that share an account; disjoint transfers still run in parallel
ConcurrencyReduced on hot accounts — waiters queue behind the lock holder
Deadlock riskRemoved when every path locks in the same account-id order
Conserved invariantEach transfer nets zero, so the total across all accounts never changes

Watch out for

Worked example

Start with A = $100, B = $50, C = $80, so the total is $230. T1 transfers $30 from A to B; T2 transfers $20 from C to A. Both T1 and T2 touch account A, so they contend on it. Locking in account-id order, T1 holds A first and runs to completion: A = $70, B = $80 (total still $230). Only then does T2 read the committed A = $70, debit C to $60, and credit A to $90. Final balances are A = $90, B = $80, C = $60 — total $230, unchanged. Had T2 read A before T1 committed, its write would have lost T1's $30 debit; the lock is exactly what prevents that.

Check yourself

1. Why does locking the two accounts in account-id order matter?

2. What anomaly happens when two transfers read a balance without locking it?

Coach note: if a pick doesn't land, give it another pass — the reasoning is what sticks.