Skip to main content
Optimistic vs Pessimistic Locking — Anselm Fowel
AI & Technology

Optimistic vs Pessimistic Locking

11 min read
346 views
Share:

Every payments engineer eventually meets the same enemy: two requests that arrive at almost the same instant, both convinced they are operating on the truth. A customer taps "pay" twice. A reconciliation job and a refund worker both reach for the same ledger row. A merchant's dashboard and our settlement engine each load a balance, do some arithmetic, and write it back. The question of who wins, and whether anyone notices they lost, is the question of concurrency control. And in practice, it almost always comes down to one decision: do you lock optimistically or pessimistically?

Optimistic vs Pessimistic Locking
Optimistic vs Pessimistic Locking

I have spent enough years in regulated fintech to know that this is not an academic distinction. The wrong choice does not crash loudly; it corrupts quietly, double-credits an account, or grinds throughput to a halt under load right when volume matters most. So I want to walk through how I actually reason about this, with the trade-offs, the failure modes, and the code patterns my teams use.

What the two strategies actually mean

Pessimistic locking assumes conflict is likely, so it takes a lock before touching the data. When you read a row with the intention of updating it, you also acquire an exclusive lock, and anyone else who wants that row waits in line behind you. The database serializes access for you. The mental model is a single key to a single room: you hold the key, you do your work, you hand it back, and only then does the next person enter.

Optimistic locking assumes conflict is rare, so it takes no lock at all during the read. Instead, it records a version marker, lets everyone proceed in parallel, and checks at write time whether the data still matches the version it read. If the version moved, someone else got there first, and the write is rejected. The mental model is closer to editing a shared document and being told, at save time, that the page has changed since you opened it.

Neither is "the safe choice" in the abstract. They are two answers to the same correctness problem, optimized for opposite assumptions about how often two writers actually collide on the same record.

How optimistic concurrency works in practice

The most common implementation is a version column. Every row carries an integer or a rowversion that increments on each update. You read the row and remember the version. When you write, your UPDATE statement includes the version you saw in its WHERE clause. If the row still has that version, exactly one row is affected and you increment it. If something changed underneath you, zero rows are affected, and that zero is your signal that you lost the race.

Here is the shape of it in SQL, deliberately explicit so the mechanism is visible:

UPDATE accounts
SET balance = @newBalance,
    version = version + 1
WHERE account_id = @accountId
  AND version = @expectedVersion;

-- If @@ROWCOUNT = 0, someone else updated the row.
-- We did NOT apply our change. Re-read and retry.
IF @@ROWCOUNT = 0
    THROW 50001, 'Concurrency conflict on account', 1;

The crucial property is that the check and the write are the same atomic statement. There is no window between "is the version still 7?" and "set it to 8" for a competitor to slip through. The database guarantees the WHERE clause is evaluated as part of the same write. That single statement is what makes optimistic concurrency correct, not the version column on its own.

Optimistic locking in .NET

Most application frameworks build this in so you do not hand-write the SQL. In Entity Framework Core, you mark a property as a concurrency token, and the generated UPDATE automatically includes it in the WHERE clause. A failed match surfaces as a typed exception you can catch and handle deliberately.

public class Account
{
    public Guid Id { get; set; }
    public decimal Balance { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = default!;
}

// Caller
try
{
    account.Balance -= amount;
    await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    // Reload current state, re-apply business rules, retry or reject.
    await entry.ReloadAsync();
    // ... decide whether the operation still makes sense
}

What I want my teams to internalize is that catching the exception is not the end of the work; it is the start of a decision. A concurrency failure is a business event, not just a technical one. "Reload and blindly retry" is only correct if re-running the operation against fresh state is still the right thing to do. For a balance debit it usually is. For "approve this loan at the rate the analyst saw five minutes ago," it absolutely is not, and you must surface the conflict to a human.

How pessimistic locking works in practice

Pessimistic locking lives mostly in the database transaction. You open a transaction, read the row with a locking hint or clause, and hold that lock until you commit. In SQL Server this is often an UPDLOCK or HOLDLOCK hint; in PostgreSQL it is SELECT ... FOR UPDATE. Anyone else who tries to lock the same row blocks until you finish.

BEGIN TRANSACTION;

SELECT balance
FROM accounts WITH (UPDLOCK, ROWLOCK)
WHERE account_id = @accountId;

-- We now hold an exclusive lock on this row.
-- Competing writers wait here until we COMMIT.

UPDATE accounts
SET balance = balance - @amount
WHERE account_id = @accountId;

COMMIT;

The appeal is obvious: there is no retry loop and no conflict to reconcile, because conflicts cannot happen. The serialization is enforced by the engine. For a short, tightly scoped critical section over a single hot row, this is often the simplest correct thing you can write. The cost is equally obvious: while you hold that lock, everyone else waits, and waiting is throughput you do not have.

The failure modes that bite

The two strategies fail in characteristically different ways, and knowing the signature of each failure tells you what you are actually dealing with at 3am.

Enjoying this article?

Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.

  • Optimistic under high contention: retry storms. If many writers hit the same row, most of them lose the version check, retry, lose again, and your effective throughput collapses while CPU climbs. The system is "working" but doing the same work repeatedly.
  • Pessimistic under load: lock convoys and blocked threads. Requests queue behind a held lock, connection pools drain, latency spikes, and timeouts cascade into dependent services. The database looks healthy; your app servers are starving.
  • Pessimistic with poor lock ordering: deadlocks. Two transactions grab two rows in opposite order and each waits forever on the other until the engine kills one as a victim.
  • Either, held too long: if a transaction or a read-modify-write spans a network call to an external service, you have turned someone else's latency into your contention.

The pattern I watch for most is the third one combined with the fourth. A pessimistic lock held across an outbound API call, a payment processor round trip, an email send, is a production incident waiting for a slow afternoon. Lock the row, do the arithmetic, commit, and only then talk to the outside world.

How I actually choose

My default in fintech is optimistic, and I move to pessimistic deliberately rather than the other way around. The reason is that optimistic concurrency keeps locks out of the picture during the long, unpredictable parts of a request, which protects tail latency and throughput, the things that actually hurt at scale. It also forces the conflict to become explicit, which I want, because a rejected write I can log, retry, or escalate is far safer than a silent overwrite.

I reach for pessimistic locking when contention on a specific record is genuinely high and the critical section is genuinely short. A single shared counter, a sequence generator, an inventory of a scarce SKU during a flash sale, a hot float account that every transaction touches: these are cases where optimistic retries would thrash, and a short exclusive lock is both simpler and faster. The discipline is keeping that locked region tiny and free of external calls.

The decision is not optimistic versus pessimistic in general. It is: how likely are two writers to collide on this exact row, and how expensive is it to redo the work if they do? Answer those two questions per use case and the strategy chooses itself.

Idempotency is the other half of the story

Concurrency control protects a single record from interleaved writers. It does not, on its own, protect you from the same logical operation arriving twice, which in payments is constant: a client retries on a timeout, a webhook is redelivered, a user double-taps. Neither optimistic nor pessimistic locking will save you here, because the two requests are not racing on stale data; they each legitimately want to move money, and both will succeed unless you stop them upstream.

This is why I treat idempotency keys as a peer concern, not an afterthought. Every state-changing endpoint accepts a client-supplied key, and we persist the result of the first attempt against that key inside the same transaction that does the work. The second attempt finds the key, returns the stored result, and never re-executes the side effect. Locking and idempotency solve different problems, and a mature payments system needs both. Confusing one for the other is how teams end up double-charging customers while insisting their code "has locking."

Distributed systems and the limits of the database

Everything above assumes a single transactional database doing the enforcement, which is the happy case. Once state is spread across services, an in-process or single-database lock no longer covers the operation, and people reach for distributed locks, a lease in Redis, a coordination service, a lock table. My strong bias is to avoid distributed locks wherever a design can make them unnecessary, because their failure modes are subtle: clock skew, a holder that dies without releasing, a network partition that lets two nodes both believe they hold the lease.

The more durable pattern is to push the authoritative state into one owner and let that owner enforce concurrency with the ordinary optimistic or pessimistic tools it already has. If the account balance lives in one service backed by one database, then a version column on that row is your concurrency control for the whole system, no distributed lock required. When genuine cross-service coordination is unavoidable, I prefer sagas and explicit compensating actions over holding a lock across service boundaries, because compensation is auditable and a stuck lock is not.

Operational discipline around locking

Whatever strategy you pick, the choice is only as good as the operational habits around it. I insist on a few non-negotiables. Set lock timeouts and statement timeouts so a stuck transaction fails fast and visibly rather than hanging the pool. Cap and instrument retries on the optimistic path, with backoff and a hard limit, so a retry loop cannot become an infinite one. Acquire multiple locks in a consistent global order to make deadlocks structurally impossible rather than merely unlikely.

Beyond that, measure. Surface concurrency-conflict rates and lock-wait times as first-class metrics, because a slowly rising conflict rate is an early warning that a row is becoming hot, and a row that is becoming hot is a redesign signpost, perhaps it should be sharded, batched, or moved behind a queue. The teams that get burned are the ones who treat locking as a thing they decided once and never looked at again. Contention is a live property of a running system, and it shifts as traffic patterns and product features change.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

Conclusion

Optimistic and pessimistic locking are not rivals to be ranked; they are tools matched to the contention profile of a specific record and the cost of redoing work when a conflict occurs. My defaults are simple to state: be optimistic by default to protect throughput and make conflicts explicit, go pessimistic for short critical sections over genuinely hot rows, never hold a lock across an external call, and remember that idempotency is a separate problem you must solve regardless. Decide per use case, instrument the result, and revisit it as the system grows. Get that right and concurrency becomes a property you can reason about, rather than a quiet source of the kind of money-moving bug that nobody wants to explain to an auditor.

Enjoyed this article? Share it with others!

Share:

Get new posts in your inbox

Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.

Comments (4)

Leave a Comment

Comments are moderated and will appear after review.

Ama Asante

September 6, 2026

The framing on "What the two strategies actually mean" alone is worth the read.

Obinna Ogbonna

August 31, 2026

Small pushback: the "Distributed systems and the limits of the database" advice maps cleanly onto high-throughput consumer payments, less so onto regulated custody where the regulator specifies the workflow, not the engineer. In our team we ended up doing the opposite and it's been the right call.

Nkechi Nwosu

August 12, 2026

If anyone hits this in terminal debug logs specifically, we had good luck with a home-rolled state machine — the operational visibility alone pays for itself.

David Mitchell

August 9, 2026

Small pushback: the "How optimistic concurrency works in practice" advice maps cleanly onto high-throughput consumer payments, less so onto B2B corporate flows where the audit trail requirement is years, not months. In our estate we ended up doing the opposite and it's been the right call.

About the author

Anselm Fowel

Anselm Fowel

Chief Technology Officer & fintech architect. 16+ years leading engineering across AlliancePay, Mondu, Transalliance, Global Accelerex, and Fidelity Bank — writing here about engineering leadership, fintech architecture, and AI in production.

Read next