What does `SELECT ... FOR UPDATE` give you that a plain `SELECT` does not, and how does `FOR SHARE` differ from it?
answer
- Plain SELECT = snapshot, no promise about the future
- FOR UPDATE = exclusive row locks held to commit
- FOR SHARE = shared; blocks writers, admits readers
- Locking read re-reads latest committed version
- SKIP LOCKED = queue workers; NOWAIT = fail fast
basics
~20 sFOR UPDATE takes exclusive row locks on the rows it reads, held until commit, so no one else can modify or lock them — it turns a read into a claim. FOR SHARE takes shared row locks: others may still read, but nobody may modify the rows until you commit.
solid answer
~60 sUnder MVCC a plain `SELECT` takes no locks and returns a snapshot, so the value it returns may be stale by the time you act on it — the classic read-modify-write race. `FOR UPDATE` closes that: it acquires **exclusive row locks** on every row returned, held to commit, blocking other writers and other `FOR UPDATE` readers of the same rows. It also re-reads the latest committed version, so at READ COMMITTED you see current data rather than your snapshot. `FOR SHARE` (`LOCK IN SHARE MODE` in older MySQL) takes **shared row locks**: several transactions may hold it simultaneously, all of them blocking writers. It is the right mode for "this row must still exist and be unchanged when I commit" — validating a foreign parent row, for example. Two practical caveats: escalating from `FOR SHARE` to a write is a classic conversion deadlock, so if you know you will write, take `FOR UPDATE` up front. And use the wait modifiers deliberately — `NOWAIT` to fail fast, `SKIP LOCKED` for queue-style work distribution.
code
sql · 4 linesBEGIN;
SELECT stock FROM items WHERE id = 42 FOR UPDATE;
UPDATE items SET stock = stock - 1 WHERE id = 42;
COMMIT;go deeper
Recall the mapping — FOR UPDATE takes exclusive row locks, FOR SHARE takes shared ones, both held until commit — and name the read-modify-write race it prevents.
Add the snapshot-bypass detail (the locking read sees the latest committed version, not your snapshot) and the wait modifiers NOWAIT and SKIP LOCKED with a use case each.
Discuss blast radius and duration: how many rows the predicate locks, keeping the transaction short, the FOR SHARE-then-UPDATE conversion deadlock, and consistent lock ordering across code paths.
Compare pessimistic locking against optimistic versioning and against queue-based serialisation, and give a rule for choosing based on contention rate, cost of redoing work, and acceptable tail latency.
## The problem it solves Consider decrementing inventory: ```sql SELECT stock FROM items WHERE id = 42; -- returns 1 -- application decides 1 >= 1, so the sale is allowed UPDATE items SET stock = stock - 1 WHERE id = 42; ``` Under MVCC the `SELECT` acquired no lock and returned a snapshot value. Two sessions can both read `1`, both decide the sale is allowed, and both decrement — leaving `-1`. The read told you the truth about the past, not a promise about the future. This is the **read-modify-write race**, and it is the single most common concurrency bug in application code. `SELECT ... FOR UPDATE` converts the read into a **claim**: it takes an exclusive row lock on each row it returns and holds it until the transaction ends. The second session now blocks at the `SELECT`, and when it is released it re-reads the current value — `0` — and correctly refuses the sale. ## The two locking-read modes **`FOR UPDATE`** — exclusive row locks. Incompatible with everything: other `FOR UPDATE` readers, `FOR SHARE` readers, and writers all wait. Use it whenever this transaction intends to modify the rows, or must be the only one allowed to act on them. **`FOR SHARE`** — shared row locks. Compatible with other `FOR SHARE` holders; blocks writers. Use it when you need the rows to remain unchanged for the duration of your transaction but do not intend to modify them yourself — verifying a parent row still exists before inserting a child, for example. This is essentially what foreign-key enforcement does internally. Both are **locking reads**, and both hold their locks to the end of the transaction regardless of isolation level. That last part matters: unlike an ordinary read lock at READ COMMITTED, these are not released when the statement finishes. ## Snapshot bypass A subtlety worth stating in an interview: in a snapshot-based engine at READ COMMITTED, a locking read does not just add a lock — after waiting for a conflicting writer it re-reads the **latest committed version** of the row, not the version from its own snapshot. PostgreSQL calls this the EPQ / re-check path; InnoDB describes it as a consistent-read exception. Without it the pattern would be useless: you would block, then act on the stale value anyway. At REPEATABLE READ the engines differ in how they reconcile the newly-read row with the transaction's snapshot: PostgreSQL raises a serialization failure rather than silently mixing versions, which the application must retry. ## The wait modifiers Standard SQL and the major engines offer three behaviours when the requested rows are already locked: - **Wait (default)** — block until the lock is free or the lock-timeout fires. - **`NOWAIT`** — raise an error immediately instead of queueing. Right for interactive paths where a user is waiting and a fast, honest failure beats an unbounded stall. - **`SKIP LOCKED`** — silently omit locked rows from the result. This is the foundation of the database-as-a-queue pattern: N workers each run `SELECT ... FOR UPDATE SKIP LOCKED LIMIT 10`, and each gets a disjoint batch with no coordination and no contention. It is deliberately non-serializable — the result set depends on who else is running — so use it only where "any available rows" is the intent. ## Conversion deadlocks The most common misuse is `SELECT ... FOR SHARE` followed by an `UPDATE` of the same rows. Two transactions both take S, both then request X, and each waits for the other's S to be released — a deadlock the detector must break. If you know you will write, take `FOR UPDATE` immediately. This is the same problem the update (U) lock mode exists to solve in engines that offer it. ## Locking too much `FOR UPDATE` locks **every row the statement returns**, so `SELECT ... FOR UPDATE` over an unbounded result set locks an unbounded number of rows for the whole transaction. Keep locking reads narrow: a primary-key or small-range predicate, ideally with `LIMIT`. Also beware joins — by default the locks apply to all tables in the query, so a locking read that joins a large lookup table locks rows there too unless you restrict it with `OF <table>`. And because these locks live until commit, the transaction wrapping them should do as little as possible: never an HTTP call, never user think-time, between the locking read and the commit. ## The alternative Pessimistic locking is not the only answer. **Optimistic concurrency** — read a version column, then `UPDATE ... WHERE id = ? AND version = ?` and check the affected-row count — takes no locks and retries on conflict. It wins under low contention and short retry loops; `FOR UPDATE` wins when conflicts are common, when the work between read and write is expensive to redo, or when several rows must be claimed together in a fixed order. ## What interviewers listen for The read-modify-write race as the motivation, the correct mode mapping (exclusive vs shared), locks held to commit, the snapshot-bypass re-read, and at least one of `SKIP LOCKED` / `NOWAIT` used for the right reason. Mentioning the FOR SHARE → UPDATE conversion deadlock, and comparing against optimistic versioning, is what separates a solid answer from a memorised one.
- When would you use SKIP LOCKED, and why is it acceptable that it returns a non-deterministic result set?For work-queue polling, where several workers claim disjoint batches of pending jobs without coordinating. Non-determinism is fine because the intent is 'give me any available work', not 'give me a specific set' — each row is still claimed by exactly one worker, since the skip is decided by real row locks. It would be wrong anywhere the result set must be reproducible or complete.
- How does optimistic locking with a version column compare to SELECT ... FOR UPDATE?Optimistic locking takes no locks: you read a version, then update with `WHERE id = ? AND version = ?` and treat zero affected rows as a conflict to retry. It scales better under low contention and never blocks, but wastes work when conflicts are frequent. FOR UPDATE serialises up front, which is preferable when contention is high, when the work between read and write is expensive, or when several rows must be claimed atomically.
- Why can `SELECT ... FOR SHARE` followed by an `UPDATE` of the same row deadlock?Both transactions acquire a shared lock on the row, then both request the exclusive lock needed to write. Neither can be granted while the other holds its shared lock, so they wait on each other and the deadlock detector aborts one. Taking `FOR UPDATE` at the initial read avoids the upgrade entirely.
saying these in an interview costs you the question
- Believing a plain SELECT under MVCC gives any guarantee that the value will still hold when you write
- Thinking FOR UPDATE locks the table — it locks the rows the statement returns (plus index entries and gaps in some engines)
- Assuming FOR UPDATE releases its locks at the end of the statement rather than at commit
- Using FOR SHARE and then updating the same rows, creating an avoidable conversion deadlock
- Holding a locking read open across an external API call or user think-time