What Is Optimistic Locking and Pessimistic Locking - and When Each One Will Hurt You
Optimistic locking and pessimistic locking are two strategies for handling the same problem: what happens when two operations try to modify the same piece of data at the same time. The term comes up constantly in database design, ORM documentation, and distributed systems discussions, but the actual mechanism behind each is simpler than the jargon suggests.
Pessimistic locking assumes conflict is likely, so it acquires an exclusive lock before reading the data — nobody else can touch the row until you release it. Optimistic locking assumes conflict is rare, so it lets everyone read freely and only checks for a collision at write time, typically by comparing a version number or timestamp against what was there when you first read.
Neither is universally better. The right choice depends on how often your writes actually collide, how long your transactions hold data open, and whether your users can tolerate a retry.
| Concept | Optimistic Locking / Pessimistic Locking |
|---|---|
| Where it sits | Concurrency control — sits at the data access layer, between your application logic and the storage engine |
| In one sentence | Two strategies for preventing lost updates: one blocks other writers up front, the other detects collisions after the fact. |
The 60-Second Answer
When two operations read the same row and both try to write back a change, one update silently overwrites the other — a lost update. Pessimistic locking prevents this by making the first reader grab an exclusive lock so nobody else can read-for-update until it commits. Optimistic locking prevents it by attaching a version marker to the row; at write time, if the version has changed since you read it, your write is rejected and you retry. Pessimistic locking trades throughput for safety. Optimistic locking trades retry complexity for throughput. Pick based on your actual collision rate, not on which one sounds cleaner.
How It Actually Works
The mechanism, step by step
- 1. The lost update problem both strategies exist to solve
- 2. Pessimistic locking acquires an exclusive lock before reading
- 3. Optimistic locking checks a version marker at write time
- 4. What happens on a conflict: retry versus wait
- 5. The version column: integers, timestamps, and hashes
- 6. Scope of the lock: row, page, table, and application-level
- 7. Interaction with transaction isolation levels
- 8. Deadlocks: the failure mode unique to pessimistic locking
1. The lost update problem both strategies exist to solve
Why you need either one at all
Suppose two HTTP requests read an account balance of 100, each subtracts 10 independently, and each writes back 90. The balance should be 80 but it is 90 — one deduction was lost. This is the lost update anomaly. It happens any time a read-then-write sequence is not atomic and another writer slips in between your read and your write.
Both optimistic and pessimistic locking exist to close this gap. They differ only in when they intervene: before the read or at the write.
⚠️ In production — Lost updates do not require high concurrency. Two users editing the same config row in an admin panel once a day is enough to hit it. The frequency of the bug is what makes it dangerous — it is rare enough to escape testing but common enough to corrupt data in production.
2. Pessimistic locking acquires an exclusive lock before reading
Block first, then work safely
With pessimistic locking, you tell the database to lock the row when you read it. In SQL this is typically SELECT ... FOR UPDATE. The database places a row-level exclusive lock that prevents any other transaction from acquiring its own FOR UPDATE lock on the same row until you commit or roll back.
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- row is now locked; other FOR UPDATE readers block here
UPDATE accounts SET balance = balance - 10 WHERE id = 42;
COMMIT;
The second transaction physically waits. There is no collision to detect because the lock made collisions impossible.
⚠️ In production — The lock is held for the duration of the transaction. If your transaction includes a slow API call, a network round trip to another service, or user think-time, every other writer on that row is blocked for that entire duration. This is the single most common way pessimistic locking becomes a bottleneck — long-held locks that started out fast and grew as feature requirements expanded.
3. Optimistic locking checks a version marker at write time
Read freely, verify on commit
With optimistic locking, every row carries a version column — an integer counter or a timestamp. When you read the row, you note the current version. When you write, your UPDATE includes a WHERE clause that checks the version has not changed. If it has, zero rows are updated, and your application knows it lost the race.
SELECT balance, version FROM accounts WHERE id = 42;
-- application reads: balance=100, version=7
UPDATE accounts
SET balance = 90, version = 8
WHERE id = 42 AND version = 7;
-- affected_rows == 0 means someone else wrote first
No lock is held between the read and the write. Other transactions can read and write freely in that window.
⚠️ In production — The version check and update must be atomic — a single UPDATE statement. If you read the version in one query and check it in application code before issuing a separate UPDATE, you have reintroduced the race condition you were trying to prevent.
4. What happens on a conflict: retry versus wait
The cost model diverges here
When pessimistic locking detects contention, the blocked transaction waits. The database queues it until the lock holder commits. The caller experiences latency but not an error.
When optimistic locking detects a conflict, the application receives a failure signal — zero affected rows, or an ORM-level version conflict exception. The application must then re-read, re-apply its logic, and re-attempt the write. This retry loop is your responsibility to build.
The retry is not free. If the business logic involves external side effects (sending an email, calling a payment API), you need to make sure those side effects are idempotent or deferred until after the write succeeds.
⚠️ In production — Under sustained contention, optimistic locking can produce retry storms — every writer keeps colliding, retrying, and colliding again. If your collision rate on a hot row is above a few percent, optimistic locking can burn more total CPU than pessimistic locking would have, because every failed attempt did real work before being thrown away.
5. The version column: integers, timestamps, and hashes
Choosing the right conflict marker
An integer counter incremented on every write is the simplest and most reliable version marker. It is monotonic, cannot collide accidentally, and is cheap to compare.
A timestamp (last-modified) works but has a subtle failure mode: two writes within the same clock resolution both see the same timestamp and both succeed. On databases with microsecond precision this is unlikely for human-driven workflows but entirely possible for automated batch processes.
Some systems use a hash of the row contents — if the hash has not changed, the row has not changed. This avoids a dedicated version column but is more expensive to compute and compare, especially on wide rows.
⚠️ In production — ORMs like Hibernate and ActiveRecord manage the version column automatically, but they only protect against conflicts that go through the ORM. A raw SQL migration, a bulk update script, or another service writing directly to the table will skip the version increment and silently break the optimistic locking guarantee.
6. Scope of the lock: row, page, table, and application-level
Granularity determines throughput
Pessimistic locks can operate at different granularities. Row-level locks (the default for SELECT ... FOR UPDATE in most databases) block only the specific rows you selected. Some storage engines escalate to page-level or table-level locks under memory pressure or when the optimizer decides a table scan is cheaper than index lookups.
Optimistic locking is inherently row-scoped — the version marker lives on each row. But you can implement application-level optimistic locking on coarser entities: a document, a configuration blob, an aggregate root. The version then protects the whole aggregate, not individual fields within it.
⚠️ In production — Lock escalation is invisible to the application. Your query locks one row, but the database may decide to lock the whole page or table. This shows up as unexpected blocking on rows you never touched. If you see unexplained lock-wait timeouts under moderate load, check whether your database is escalating locks.
7. Interaction with transaction isolation levels
Locking does not replace isolation
Locking strategy and isolation level are independent controls that interact. At READ COMMITTED, a pessimistic lock prevents lost updates on the locked rows, but a second query in the same transaction might see rows inserted by other committed transactions (a phantom read). At SERIALIZABLE, the database may add range locks or use serializable snapshot isolation to prevent phantoms, but the performance cost goes up.
Optimistic locking works at any isolation level because it relies on the application checking the version, not on the database's lock manager. However, at READ UNCOMMITTED, your version read might see an uncommitted value, which defeats the purpose.
⚠️ In production — A common mistake is assuming that setting the isolation level to SERIALIZABLE makes explicit locking unnecessary. It makes lost updates impossible, but it also means the database will abort your transaction on any serialization conflict — giving you the retry burden of optimistic locking with the overhead of heavyweight isolation. Pick one strategy, not both.
8. Deadlocks: the failure mode unique to pessimistic locking
Two locks waiting on each other forever
When transaction A locks row 1 and waits for row 2, while transaction B locks row 2 and waits for row 1, neither can proceed. This is a deadlock. The database detects it (usually within a second) and kills one transaction with an error.
Optimistic locking cannot deadlock because it never holds a lock between read and write. This is one of its strongest advantages in systems where transactions touch multiple rows in unpredictable order.
Tx A: LOCK row 1 → wants row 2 (blocked by B)
Tx B: LOCK row 2 → wants row 1 (blocked by A)
→ database kills one, the other proceeds
⚠️ In production — Deadlocks are not just a theory exercise. They are a regular operational event in any system that takes pessimistic locks on multiple rows. Your application must handle the deadlock error and retry the entire transaction — meaning pessimistic locking is not actually retry-free if your transactions span multiple rows.
- An e-commerce inventory system where hundreds of users might purchase the last few units of a popular item simultaneously. Pessimistic locking on the inventory row ensures each purchase sees the true remaining count and prevents overselling. The lock is held only for the duration of a fast database transaction, so throughput stays acceptable.
- A wiki or collaborative document editor where two people might edit the same article, but the chance they edit it at the same second is low. Optimistic locking with a version column lets both users load the page freely. If the second user submits after the first has already saved, they see a conflict message and can merge their changes. Pessimistic locking here would mean the second editor cannot even open the page while the first is editing.
- A bank ledger system processing transfers between accounts. Each transfer debits one account and credits another — two rows locked in a single transaction. Pessimistic locking guarantees atomicity, but the system must impose a consistent lock ordering (always lock the lower account ID first) to prevent deadlocks.
- A REST API for updating user profile settings where writes are infrequent and mostly come from the user themselves. Optimistic locking via an ETag header (derived from the version column) is the natural fit. The client sends
If-Matchwith the ETag it received, and the server returns409 Conflictif the row has been modified since. No locks held on the server, and conflicts almost never happen.
When You Actually Need It
- Your write collision rate is high and you cannot tolerate retries — pessimistic locking guarantees forward progress for the holder. Use it when two concurrent writers hitting the same row is the normal case, not the exception (inventory counters, seat reservations, sequential numbering).
- Your transactions are short and purely database-bound — pessimistic locks are cheap when held for milliseconds inside a fast transaction. If your lock-to-commit path is a single UPDATE with no external calls, the throughput cost is minimal.
- Your write collision rate is low and your read volume is high — optimistic locking lets all readers proceed without blocking. Use it when conflicts happen on fewer than a few percent of writes and a retry is acceptable when they do (CMS content, user settings, configuration records).
- Your data access spans a long user interaction — if the user reads data, thinks for thirty seconds, and then submits a change, you cannot hold a pessimistic lock for that duration without destroying throughput. Optimistic locking lets you detect that the data changed during the think-time without holding any database resources.
- You are working across service boundaries or across databases — pessimistic locking requires a shared lock manager. When the read and the write happen in different services or different databases, there is no single lock to take. Optimistic locking via version checks is the only practical choice.
When You Don't
- Your table has a single writer and you control the write path — if only one service writes to a table and it processes writes sequentially (a queue consumer, a cron job), there is no concurrent writer to conflict with. Neither locking strategy adds value. A simple
UPDATE ... WHERE id = ?is sufficient. - You can express the operation as an atomic increment — if the update is
SET balance = balance - 10rather thanSET balance = 90, the database handles the read-modify-write atomically. You do not need optimistic or pessimistic locking; the database's own atomicity guarantee is enough. Use this when the new value is a function of the old value and you do not need to read the old value in application code first. - Your system uses event sourcing or append-only writes — in an event-sourced system, you never update a row. You append a new event. Conflicts are resolved at the projection or read-model level. Adding row-level locking on top of an append-only model is solving a problem you do not have.
- You are building a prototype or an internal tool with fewer than a handful of users — the probability of two users editing the same record at the same second is near zero. Adding version columns, retry logic, and conflict resolution UI is engineering effort with no payoff. Ship without it and add it when you actually see a lost update in your logs.
- You are considering pessimistic locking to protect a cache or a read replica — pessimistic locks exist on the database connection that created them. A read replica or an application-level cache does not participate in the lock manager. If your architecture reads from a replica and writes to the primary, pessimistic locking on the primary does not protect the stale read from the replica. You need optimistic locking (or cache invalidation) instead.
What People Get Wrong
"Optimistic locking is always faster because it does not block."
Optimistic locking avoids blocking but pays for it with retries. Under high contention, the total work done (read, compute, fail, re-read, re-compute, succeed) can exceed the total work done by waiting in a pessimistic lock queue. The crossover point depends on your collision rate and how expensive your business logic is to re-execute.
"Pessimistic locking causes deadlocks, so you should avoid it."
Deadlocks are a manageable operational concern, not a reason to avoid pessimistic locking entirely. Consistent lock ordering eliminates most deadlocks. The remaining ones are caught by the database's deadlock detector and handled with a retry. If your transactions lock a single row, deadlocks are impossible.
"Using an ORM's @Version annotation means my data is safe from concurrent modification."
ORM-managed optimistic locking only protects writes that go through the ORM. Direct SQL updates, database migrations, bulk imports, and writes from other services that share the table will not increment the version column. If any write path bypasses the ORM, the version check gives a false sense of safety.
"SELECT FOR UPDATE locks the row for reads too — nobody can even SELECT it."
In most databases (PostgreSQL, MySQL/InnoDB), a FOR UPDATE lock blocks other FOR UPDATE or FOR SHARE acquisitions, but a plain SELECT without FOR UPDATE still reads the row freely using MVCC. The row is not invisible; it is just protected from concurrent locking reads and writes.
Why Two Strategies Instead of One
The split between optimistic and pessimistic locking exists because the database cannot know your workload's conflict profile in advance. A pessimistic lock is a reservation: it guarantees exclusive access at the cost of serializing all other writers. A reservation system works well when demand is high and the cost of a failed attempt is expensive — think airline seat assignments or financial ledger entries. The design assumes that if you are reading data with intent to modify it, someone else probably is too.
An optimistic check is a bet: it assumes most of the time, nobody else will touch the same row in the few milliseconds between your read and your write, and it is willing to throw away work in the rare case that bet is wrong. This design choice was driven by systems where reads vastly outnumber writes and where holding database locks for the duration of a user interaction (a web form submission, an API call with network latency) would serialize throughput to an unacceptable level. The version column is the cheapest possible concurrency check — one integer comparison in the WHERE clause — and it moves the conflict resolution into the application layer where domain-specific merge logic can live.
The fundamental tradeoff is wasted waiting versus wasted work. Pessimistic locking wastes time when there is no conflict — the lock was acquired for nothing. Optimistic locking wastes computation when there is a conflict — the entire read-compute cycle must be discarded and replayed. Neither waste is free, and the right choice depends on which one your system can better absorb. Systems that process financial transactions, where correctness on every single write matters and retries have regulatory implications, lean pessimistic. Systems that serve web traffic, where throughput and latency percentiles matter more than any single write, lean optimistic. Most systems fall somewhere in between and use both strategies on different tables.
Related Briefs
What to Read Next
- MVCC (Multi-Version Concurrency Control)
- database transaction isolation levels
- distributed locking with Redis or ZooKeeper
Use pessimistic locking when conflicts are frequent, transactions are fast, and you want the database to serialize access for you. Use optimistic locking when conflicts are rare, reads dominate, and you can afford to build retry logic. If your writes never collide — because there is one writer, or because the update is an atomic expression — skip both and save yourself the complexity. The most common mistake is not picking the wrong strategy; it is adding either one to a table that does not need it yet.
댓글
댓글 쓰기