Topic: Databases

Concrete problem

A table jobs holds pending jobs. Ten worker processes each want one job. Each worker runs:

SELECT * FROM jobs WHERE state = 'pending' ORDER BY id LIMIT 1 FOR UPDATE;

Worker 1 locks job 1. Workers 2 to 10 want the same row. All nine wait for worker 1. Ten workers do the work of one. How can the workers avoid waiting for each other?

A second problem: a web request must claim a seat. If another request holds the seat, the web request must tell the user “busy” at once. It must not hang.

Background terms

Wait policy

A wait policy is the rule that a requester follows when its lock request conflicts with a lock that another transaction holds. The wait policy does not change which lock modes conflict. It only changes what the requester does after a conflict.

Job queue

A job queue is a table (or other store) of work items. Many workers take items from it. Each item must go to exactly one worker.

SQLSTATE 55P03

SQLSTATE is the error code that Postgres sends with an error. The code 55P03 means lock_not_available. A request that gives up on a lock raises this error.

Intuition

Take the moment when B asks for a row lock and the lock conflicts with one that A holds. B has only three moves.

  1. Wait. B stops until A ends. Postgres then re-checks the row and continues. This is the default.
  2. Fail. B gives up and raises an error. The application decides what to do next.
  3. Skip. B acts as if the locked row does not exist. B continues with other rows.

B cannot use the row, because the lock forbids it. So these three moves are the only moves. Postgres gives one clause for each non-default move:

MoveClauseScope
Wait(none)Waits without a limit
Wait with a limitSET lock_timeout = '2s'Session setting. Raises 55P03 after the limit.
FailNOWAITPer statement. Raises 55P03 at once.
SkipSKIP LOCKEDPer statement. Row locks only.

SKIP LOCKED fits a job queue. Each worker skips the jobs that other workers hold. Each worker gets a different job.

SKIP LOCKED gives an incomplete view on purpose. The result does not contain every row that matches. Use it for queues. Do not use it where every matching row must appear in the result.

Numeric example

Five pending jobs: 1, 2, 3, 4, 5. Three workers start at the same moment. Each worker runs ... ORDER BY id LIMIT 1 FOR UPDATE ....

Default (wait).

WorkerLocksWhat happens
W1job 1Works on job 1.
W2wants job 1Waits for W1.
W3wants job 1Waits for W1.

Only W1 works. W2 and W3 wait until W1 commits. The three workers run one after the other.

With SKIP LOCKED.

WorkerLocksWhat happens
W1job 1Works on job 1.
W2job 2Skips job 1 (locked). Works on job 2.
W3job 3Skips jobs 1 and 2 (locked). Works on job 3.

Three jobs run at the same time. Nobody waits.

With NOWAIT. W2 tries job 1 first. Job 1 is locked. W2 raises error 55P03 at once. W2 does not try job 2.

Visual

graph TD
  C["B asks for a row lock.<br/>A holds a conflicting lock."] --> W["No clause: B waits"]
  C --> T["lock_timeout: B waits, then raises 55P03"]
  C --> N["NOWAIT: B raises 55P03 at once"]
  C --> S["SKIP LOCKED: B skips the row and continues"]

Notation

The clause goes at the end of a locking SELECT:

SELECT ... FOR UPDATE NOWAIT;
SELECT ... FOR UPDATE SKIP LOCKED;
SELECT ... FOR SHARE SKIP LOCKED;

The clause works with all four row lock modes.

The usual job queue statement:

UPDATE jobs SET state = 'running'
WHERE id = (
  SELECT id FROM jobs
  WHERE state = 'pending'
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING *;

The inner SELECT finds one job that no worker holds and locks it. The outer UPDATE marks it as running.

Table locks also accept NOWAIT: LOCK TABLE t IN ... MODE NOWAIT. SKIP LOCKED works on row locks only.

Plain-English translation

  • “NOWAIT” means “If the row is busy, tell me with an error. Do not make me wait.”
  • “SKIP LOCKED” means “If the row is busy, leave it out. Give me the other rows.”
  • “lock_timeout” means “Wait for the lock, but only this long.”

The wait policy belongs to the request. The conflict table belongs to the lock modes. These two parts are independent.

Equation

There is no equation. A rule describes the three outcomes. Let be the set of rows that the statement wants. Let be the rows that another transaction holds in a conflicting mode.

  • Wait: the result contains after the locks on end.
  • NOWAIT: the result is an error if . Otherwise it is .
  • SKIP LOCKED: the result is .

What changes when parameters change

  • No row is locked (). All three policies give the same result.
  • LIMIT with SKIP LOCKED. The statement skips locked rows and keeps looking until it has enough unlocked rows or the table ends.
  • LIMIT with NOWAIT. The statement locks rows in order. It raises the error when it meets the first locked row, even if later rows are free.
  • Many locked rows. SKIP LOCKED returns fewer rows than asked. A worker must accept an empty result.
  • SKIP LOCKED on a plain SELECT (no lock clause). This is not allowed. The clause belongs to a locking clause.

Practice problem

Jobs 1 to 5 are pending. A holds FOR UPDATE on job 1 and job 2. B runs SELECT id FROM jobs WHERE state='pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 2.

  1. Which jobs does B get?
  2. B runs the same statement with NOWAIT in place of SKIP LOCKED. What happens?

Answers: jobs 3 and 4. An error 55P03 at once.

Demo

Each scenario opens real concurrent sessions against a local Postgres. The log shows every statement with a time stamp. The folder is 03-wait-policies.

ScenarioWhat it shows
01-nowaitNOWAIT raises an error at once.
02-lock-timeoutlock_timeout raises an error after a fixed wait.
03-skip-locked-queueA job queue. SKIP LOCKED runs in parallel. The default wait runs in line.

To run one scenario, start Postgres with ./up.sh. Then run go run ./03-wait-policies/01-nowait in the repository. To run all scenarios of this concept, use ./run.sh 03-wait-policies.

Quiz

Asked on 2026-10-05 after the lesson. All three answers were correct.

Q1. Jobs 1 to 5 are pending. A holds FOR UPDATE on job 1 and has not committed. B runs SELECT id FROM jobs WHERE state = 'pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 2. What does B get?

  • Job 1 and job 2, after A ends
  • Job 2 only
  • Job 2 and job 3 (your answer, correct)
  • An error at once

Why: SKIP LOCKED leaves out job 1. The statement keeps looking until it has 2 unlocked rows. The first option is the “wait” result. The second option forgets that LIMIT 2 keeps looking. The fourth option is the NOWAIT result.

Q2. Same setup, but B uses NOWAIT in place of SKIP LOCKED. What does B get?

  • Job 2 and job 3
  • An error at once (your answer, correct)
  • An empty result
  • Job 1 and job 2, after A ends

Why: B tries to lock job 1 first, because of ORDER BY id. Job 1 is locked. NOWAIT raises error 55P03 at once. B does not try job 2.

Q3. Which statement about wait policies is true?

  • SKIP LOCKED makes FOR UPDATE a weaker lock mode
  • NOWAIT makes the lock end sooner
  • SKIP LOCKED returns every row that matches the WHERE clause
  • A wait policy changes what the requester does on a conflict, not which modes conflict (your answer, correct)

Why: the wait policy belongs to the request. The conflict table belongs to the lock modes. The two are independent. SKIP LOCKED returns fewer rows on purpose.

My reasoning

Quiz on 2026-10-05: all three correct. The user applied the three outcomes (wait, fail, skip) to a LIMIT 2 case without error.

Session history