open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 07 · 03

SELECT FOR UPDATE and the job queue

Pessimistic row locks fix the lost-update race: FOR UPDATE locks rows you read so the next write is safe. FOR UPDATE SKIP LOCKED turns one table into a contention-free concurrent job queue.

SQL Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A team built a worker pool against a jobs table: each worker ran SELECT id FROM jobs WHERE status='queued' LIMIT 1 then marked it running. With one worker it was flawless. At ten workers, every job got picked up by three or four of them — duplicate emails, triple charges, the works. The fix was eight characters: FOR UPDATE SKIP LOCKED. The same eight characters turn a plain table into a production-grade concurrent queue. This is the most useful locking clause in Postgres.

The lost-update race, and the pessimistic fix

Before you reach for FOR UPDATE, ask yourself: am I reading a value and then writing a decision based on it? If yes, and if another session could do the same thing concurrently, you have a lost-update window right now.

The default READ COMMITTED level does not serialize a read-then-write done in two statements. Two sessions can both read a balance, both compute a new value off the stale read, and both write — the second silently overwrites the first. That is the lost update.

-- Session 1 and Session 2 both run this, interleaved:
SELECT balance FROM accounts WHERE id = 1;   -- both read 500
-- app computes 500 - 100 = 400
UPDATE accounts SET balance = 400 WHERE id = 1;  -- both write 400; one debit vanishes

SELECT ... FOR UPDATE fixes it pessimistically: it takes a row-level write lock on every row it returns. A second transaction that tries to lock the same row blocks until the first commits, then proceeds against the now-current data.

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- locks the row
-- only this transaction holds it; others wait here
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- lock releases; the next waiter re-reads the fresh balance

The lock lives for the rest of the transaction and releases on COMMIT or ROLLBACK. The cost is serialization: contended rows process one transaction at a time. That’s the point — correctness over throughput on the hot row.

The four lock strengths

Postgres has four row-lock modes, from strongest to weakest. Picking the weakest one that’s correct lets more concurrency through.

  • FOR UPDATE — full write lock. Blocks any other FOR UPDATE/FOR SHARE and blocks UPDATE/DELETE of the row. Use when you will modify the row.
  • FOR NO KEY UPDATE — slightly weaker; taken automatically by a plain UPDATE that doesn’t touch a key column. It does not block FOR KEY SHARE, which is what foreign-key checks take — so it reduces FK-related contention.
  • FOR SHARE — shared read lock. Multiple transactions can hold it; it blocks writers. Use to pin a row you’re reading but not changing.
  • FOR KEY SHARE — weakest; what a foreign-key existence check acquires on the parent row. It only blocks changes to the row’s key.

The practical lesson: a child-table INSERT takes FOR KEY SHARE on the parent, and a parent UPDATE of a non-key column takes FOR NO KEY UPDATE — these two were made compatible so ordinary FK traffic doesn’t serialize. Reach for plain FOR UPDATE only when you truly intend to update the locked row.

SKIP LOCKED: the canonical concurrent queue

SKIP LOCKED changes the wait behaviour: instead of blocking on an already-locked row, the query skips it and returns the next available one. That is exactly what a job queue needs — each worker grabs a different unlocked job.

-- One worker's atomic dequeue: claim the oldest free job, skipping any a peer holds.
BEGIN;
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- mark the claimed row so it won't be re-selected after commit:
UPDATE jobs SET status = 'running', locked_at = now() WHERE id = :claimed_id;
COMMIT;

Ten workers running this in parallel each lock and claim a distinct row; no duplicates, no blocking, near-linear scaling. This is how Sidekiq-Pro, pg-boss, Que, and most modern Postgres queues work — no separate broker, just FOR UPDATE SKIP LOCKED.

Edge cases

Two more knobs. NOWAIT is the opposite of SKIP LOCKED: instead of waiting or skipping, it errors immediately (55P03) if any target row is locked — useful when “someone else is editing this” should be an instant failure, not a wait. And consistent lock ordering is the rule that prevents deadlocks (next lesson): if every transaction that locks multiple rows always locks them in the same order — e.g. always ORDER BY id before FOR UPDATE — two transactions can never each hold a row the other wants. A money transfer locking accounts 1 and 2 must lock the lower id first in both directions of the transfer, or you’ll deadlock under load.

Quiz

A job-queue worker uses SELECT ... FOR UPDATE (without SKIP LOCKED) with LIMIT 1 to claim a job. With ten workers, what happens?

Quiz

You want 'editing this record fails instantly if another session is already editing it' — no waiting, no skipping. Which clause?

Order the steps

Order one worker's atomic, lost-update-free dequeue from a jobs table shared by ten workers:

  1. 1 BEGIN a transaction so the claim and the status update are one unit
  2. 2 SELECT the oldest queued job with ORDER BY id ... FOR UPDATE SKIP LOCKED LIMIT 1
  3. 3 UPDATE that row to status='running', locked_at=now()
  4. 4 COMMIT to release the lock and make the claim durable
Recall before you leave
  1. 01
    What is the lost-update problem and how does SELECT FOR UPDATE prevent it?
  2. 02
    How does FOR UPDATE SKIP LOCKED build a concurrent job queue, and what does the dequeue query look like?
  3. 03
    When would you use NOWAIT instead of SKIP LOCKED or a plain lock, and how do they differ on a locked row?
Recap

The default isolation level leaves a two-statement read-then-write open to the lost update: two sessions read the same value, compute from the stale read, and both write, dropping one change. SELECT ... FOR UPDATE closes it pessimistically by taking a row write lock at read time; a competing locker blocks until you commit, then works from fresh data, so the hot row is processed one transaction at a time. Postgres offers four lock strengths — FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE — and you take the weakest that’s correct so ordinary foreign-key traffic doesn’t serialize. The standout pattern is FOR UPDATE SKIP LOCKED: it steps past rows other workers hold, so a plain jobs table becomes a contention-free concurrent queue where each worker claims a distinct row with ORDER BY id ... LIMIT 1. Round it out with NOWAIT for fail-fast editing and a consistent lock order to avoid the deadlocks you’ll study next. Now when you see a duplicate-job or double-charge bug, your first instinct will be to check whether the dequeue query uses FOR UPDATE SKIP LOCKED — and if not, you’ll know exactly what to add.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.