Optimistic vs pessimistic locking: which one, and when?
Lock first and make others wait, or go ahead and check for a conflict when you write. Choose by how often writers collide.
By devskills editorial team · Last updated
What problem does the optimistic vs pessimistic choice solve?
When two requests may change the same record, something must stop the second from silently overwriting the first. Pessimistic locking assumes a conflict will happen: it locks the row before reading it, and anyone else who wants it waits. Optimistic locking assumes a conflict is rare: it reads without locking, and at write time checks that nobody changed the row in the meantime, retrying if they did.
Both are correct. They differ in where the cost lands, waiting versus retrying, and that depends on how often writers actually collide. The broader page on transactions and locking introduces both; this one is about choosing between them.
What the optimistic vs pessimistic choice costs you
When to reach for the optimistic vs pessimistic choice, and when not to
Use it when
- Optimistic: conflicts are rare, such as editing your own profile, admin screens, most CRUD, and any edit that spans user think-time across HTTP requests.
- Pessimistic: conflicts are frequent and retries are expensive, such as a hot inventory row, or a job queue where workers claim rows.
- Pessimistic with SKIP LOCKED: many workers pulling distinct jobs from one table without blocking each other.
Do not, when
- Pessimistic locking across a user's think-time or a network call. A lock held while someone types, or while a payment API responds, blocks everyone else.
- Optimistic locking on a row that thousands of requests update per second. Most attempts fail and retry, and throughput falls.
- Either one, when a single atomic statement can do the check and the write together.
How the optimistic vs pessimistic choice actually works
The curve is why the choice depends on contention. At low p, optimistic locking is almost free. As p approaches 1, the retries are the workload, and making writers wait in line is cheaper.
Pessimistic, in SQL. Inside a transaction, SELECT ... FOR UPDATE locks the selected rows until commit or rollback. Another transaction that tries to lock or update them waits. Two options change the waiting: NOWAIT fails immediately if the row is locked, and SKIP LOCKED skips locked rows, which turns a table into a work queue that many workers can poll without blocking each other. Both are supported by PostgreSQL and by MySQL 8.0 and later. Set a lock timeout as well, so a stuck transaction cannot hold everyone up forever.
Optimistic, in SQL. Add a version column (or use an update timestamp). Read the row and its version. Write with UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?, then check the number of rows affected. One row means you won. Zero means someone changed it after you read: re-read, re-apply your change if it still makes sense, and try again, with a cap on attempts. No lock is held between the read and the write, which is what lets the read happen in one HTTP request and the write in another, minutes later.
The option that beats both. When the decision fits in one statement, let the database do it atomically: UPDATE stock SET qty = qty - 1 WHERE sku = ? AND qty >= 1. There is no separate read, so there is no gap to protect, no lock held across application code, and no retry. Reach for optimistic or pessimistic locking when the new value needs logic the database cannot express in the WHERE clause.
Deadlocks are a pessimistic problem. Transaction A locks account 1 then wants account 2; transaction B locks 2 then wants 1. The database detects the cycle and aborts one. Prevent it by always locking in the same order (for example, lowest id first) and keeping transactions short. Optimistic locking cannot deadlock, because it holds nothing while it works.
The same idea outside the database. HTTP has optimistic concurrency built in: the server returns an ETag, the client sends it back in If-Match with its update, and the server answers 412 Precondition Failed if the resource changed. Redis offers WATCH with MULTI/EXEC: the transaction is discarded if a watched key changed. DynamoDB and many key-value stores offer conditional writes. ORMs wrap the version column for you, such as JPA's @Version.
Distributed locks are pessimistic locks with extra failure modes. A lock held in Redis or ZooKeeper can expire while its holder is paused (a long GC pause, a network stall), and the holder then writes believing it still owns the lock. The standard defence is a fencing token: a number that increases with every lock grant, passed with each write, so the storage rejects writes carrying an older token. Martin Kleppmann's essay on distributed locking explains it well.
Choosing, in one sentence. Estimate how often two writers touch the same record within the time it takes to do the work. Rarely: optimistic. Often, with expensive work: pessimistic. Often, with work that fits one statement: an atomic conditional update. The ticket booking problem is a good place to practise all three.
| Pessimistic locking | Optimistic locking | |
|---|---|---|
| Mechanism | SELECT ... FOR UPDATE, then update, then commit | Read with a version, then UPDATE ... WHERE version = ? |
| Conflict detected | Before the work: the second writer waits | At write time: zero rows updated |
| Cost with no conflict | A lock taken and released | Almost none |
| Cost under contention | Writers queue; throughput is one at a time per row | Retries; wasted work grows quickly |
| Deadlocks | Possible; lock in a fixed order | Not possible |
| Holds a connection while you think | Yes, for the whole transaction | No |
| Works across HTTP requests | No | Yes (version or ETag round-trips with the client) |
| Good for | Hot rows, job queues (SKIP LOCKED), expensive work | Most CRUD, user edits, low-contention records |
-- pessimistic: lock, decide, write, commitBEGIN;SELECT qty FROM stock WHERE sku = 'A1' FOR UPDATE; -- others wait here-- application checks qty, computes the new valueUPDATE stock SET qty = 4 WHERE sku = 'A1';COMMIT;-- a job queue: each worker claims a different row, nobody blocksSELECT id FROM jobs WHERE state = 'ready'ORDER BY id LIMIT 1FOR UPDATE SKIP LOCKED;-- optimistic: no lock; the WHERE clause detects a conflictSELECT qty, version FROM stock WHERE sku = 'A1'; -- say qty 5, version 12UPDATE stock SET qty = 4, version = version + 1WHERE sku = 'A1' AND version = 12; -- 0 rows: someone won, retryvar ErrConflict = errors.New("stock changed too often, try again")func Reserve(ctx context.Context, db *sql.DB, sku string, n int) error { for attempt := 0; attempt < 3; attempt++ { var qty, version int err := db.QueryRowContext(ctx, "SELECT qty, version FROM stock WHERE sku = $1", sku).Scan(&qty, &version) if err != nil { return err } if qty < n { return ErrOutOfStock } res, err := db.ExecContext(ctx, "UPDATE stock SET qty = $1, version = version + 1 WHERE sku = $2 AND version = $3", qty-n, sku, version) if err != nil { return err } if rows, _ := res.RowsAffected(); rows == 1 { return nil // nobody changed it between our read and our write } // zero rows: another writer got there first; read again and retry } return ErrConflict}- Holding a
FOR UPDATElock while calling a payment provider or waiting for a user. - Optimistic locking with unlimited retries. Cap the attempts and return a clear conflict error.
- Forgetting to check rows affected, which turns the version check into a no-op.
- Reaching for either one when a single conditional
UPDATEwould do the check and the write atomically.
The follow-up an interviewer will ask about the optimistic vs pessimistic choice
"Flash sale: 10,000 people try to buy the same 100 items in one second. Optimistic or pessimistic?"
Neither as the first choice. Optimistic locking would be the worst here: nearly every attempt conflicts, so each buyer retries several times and the database does many times the useful work. Pessimistic locking is correct, but every buyer queues on one row lock, holding a connection while they wait, so the connection pool empties and unrelated pages slow down. The better answer is to remove the read-then-write entirely: one conditional decrement, update stock set quantity minus one where the item matches and quantity is at least one, and look at rows affected. The database serialises those statements on the row with no application logic in between, so each takes microseconds. If that row is still too hot, I would put a queue in front so requests are admitted at a rate the row can take, or split the stock into several rows and decrement any one with stock left. Cost: the queue adds waiting for buyers, and split stock means the last few units can sit in one row while another reports none left, which needs a sweep.
Optimistic vs pessimistic locking: common questions
Does optimistic locking actually lock anything?
Not in the sense of making others wait. The database still locks the row briefly while executing the UPDATE, as it does for any write, but nothing is held between your read and your write. The "lock" is the version check in the WHERE clause.
What is the difference between NOWAIT and SKIP LOCKED?
With NOWAIT, a locking read fails at once with an error if a row is already locked. With SKIP LOCKED, it silently leaves locked rows out of the result. NOWAIT suits "tell the user it is busy"; SKIP LOCKED suits queues where any free row will do.
How does optimistic locking work in a REST API?
The GET response carries an ETag (often the row version). The client sends it back in an If-Match header on PUT or PATCH. If the resource changed, the server responds 412 Precondition Failed, and the client re-fetches and retries or shows the conflict.
Which is better for high concurrency?
It depends on whether the concurrent requests touch the same rows. Many requests on different rows: optimistic, since conflicts stay rare. Many requests on the same row: pessimistic locking, a queue, or ideally an atomic conditional update, because optimistic retries multiply the load.
Your turn
A wiki lets users edit a page in a form for several minutes and then save. Which locking approach, and why not the other?
Learn it from the source
- PostgreSQL — The locking clause (FOR UPDATE, NOWAIT, SKIP LOCKED) ↗Exactly what each row-locking option does.
- RFC 9110 — Conditional requests ↗ETag, If-Match and 412 Precondition Failed: optimistic concurrency in HTTP.
- Redis — Transactions (WATCH) ↗Optimistic check-and-set with WATCH, MULTI and EXEC.
- Martin Kleppmann — How to do distributed locking ↗Why lock expiry needs fencing tokens.