A PostgreSQL query using FOR UPDATE SKIP LOCKED can omit a row that satisfies its predicate solely because another transaction already holds a conflicting row lock. The omitted row has not stopped matching the query. It is absent from that execution because lock acquisition would wait.

That behavior changes the meaning of a locking read. Ordinary selection asks which visible rows satisfy a predicate. SKIP LOCKED adds an operational condition: among qualifying rows, return only those whose requested locks can be acquired without waiting at the point PostgreSQL attempts to lock them.

Lock availability becomes part of the observed result

Consider a table used to represent pending jobs:

CREATE TABLE job (
    job_id bigint PRIMARY KEY,
    state text NOT NULL,
    created_at timestamptz NOT NULL
);

A worker can select one candidate while requesting a row lock:

SELECT job_id
FROM job
WHERE state = 'pending'
ORDER BY created_at, job_id
FOR UPDATE SKIP LOCKED
LIMIT 1;

If the first qualifying row is already locked incompatibly by another transaction, PostgreSQL does not wait for that lock when SKIP LOCKED applies. It can continue and return a later qualifying row whose lock is immediately obtainable.

The resulting row set therefore is not a stable statement about all predicate matches. PostgreSQL documentation characterizes SKIP LOCKED as producing an inconsistent view of the data, while also identifying queue-like access as a suitable use because several consumers can avoid contending for the same rows.

This is a deliberate semantic trade. The query gives up completeness of the candidate view in exchange for avoiding waits on row-level lock conflicts.

SKIP LOCKED changes waiting, not visibility rules

SKIP LOCKED does not create a separate MVCC visibility model. Rows still become candidates according to the transaction’s snapshot and the normal rules of the active isolation level. The modifier acts when PostgreSQL attempts to acquire the requested row lock.

That separation matters. A row can be absent because it is not visible to the snapshot, because it does not satisfy the predicate, or because it is visible and qualifying but cannot be locked immediately. Only the last case is the distinctive omission introduced by SKIP LOCKED.

Likewise, the clause does not mean that every form of blocking disappears. PostgreSQL still takes the table-level ROW SHARE lock associated with SELECT ... FOR UPDATE or related locking clauses. A conflicting table-level lock can therefore still make the statement wait. The skip behavior applies to row-level lock conflicts, not to all lock acquisition performed by the statement.

LIMIT bounds successful lock acquisition

The interaction with LIMIT is central to queue-style selection. PostgreSQL stops locking rows once enough rows have been returned to satisfy the limit. Rows skipped because of incompatible row locks do not consume the requested result count.

With LIMIT 1, a worker can pass over several locked candidates before acquiring and returning one later row. Two concurrent workers executing the same ordered query can consequently receive different jobs even when their candidate ordering begins identically.

This is not equivalent to partitioning the table in advance. The division emerges from lock state at execution time. A transaction that commits or rolls back can make a previously skipped row available to a later statement.

The candidate order and the lock state therefore jointly determine which row a worker receives.

Ordering does not imply global queue order under contention

An ORDER BY clause controls the order in which candidate rows are considered according to the query plan and SQL semantics, but SKIP LOCKED permits an earlier candidate to be bypassed when its lock conflicts.

A queue ordered by creation time can thus process a newer job while an older qualifying job remains locked elsewhere. If the older transaction stays open for a long interval, repeated consumers can continue selecting later rows.

That property means ORDER BY created_at plus SKIP LOCKED does not establish strict first-in, first-out completion. It expresses a preference among candidates that are available for locking during each execution.

Strict ordering and non-blocking parallel claim behavior pull in different directions. Waiting for the earliest locked row preserves a stronger ordering boundary but can serialize consumers behind that row. Skipping it preserves consumer progress but relaxes global order.

A row lock is a claim, not a durable state transition

Selecting a job FOR UPDATE SKIP LOCKED protects the chosen row from conflicting row-level operations until the transaction releases its lock. The lock itself does not change state.

A common transaction therefore couples selection with a state mutation:

WITH picked AS (
    SELECT job_id
    FROM job
    WHERE state = 'pending'
    ORDER BY created_at, job_id
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE job
SET state = 'running'
FROM picked
WHERE job.job_id = picked.job_id
RETURNING job.job_id;

Within one transaction, the locking selection prevents another conflicting locker from claiming the same row through the same pattern while the update records a durable state change if the transaction commits.

If the transaction rolls back, both the update and the row lock are undone. The job can again appear as pending to later transactions according to normal visibility rules.

The distinction is important for failure semantics. Lock ownership is transient coordination. Queue state stored in the row is transactional data. A design that needs a durable claim after the claiming transaction ends must represent that claim in committed data or another durable mechanism rather than relying on the released row lock.

Isolation level can change later observations

At READ COMMITTED, each command obtains a fresh snapshot. A row skipped by one statement can become available to a later statement in the same transaction after the conflicting locker finishes, subject to the state visible to that later command.

At stronger snapshot isolation, the transaction retains a more stable snapshot boundary, and locking a row changed since that snapshot can lead to serialization-related failure rather than simply exposing the newest version as an ordinary candidate.

SKIP LOCKED does not erase those isolation-level rules. It only changes the response to a row that cannot receive the requested lock immediately. Systems that retry claims need to distinguish lock omission from transaction failure caused by their isolation semantics.

Starvation is outside the clause’s guarantee

The clause guarantees skip behavior for conflicting row locks; it does not guarantee that every qualifying row will eventually be selected.

A row that remains repeatedly locked when consumers scan the queue can be bypassed repeatedly. Whether that turns into practical starvation depends on transaction duration, retry behavior, ordering, arrival rate, and application state transitions. PostgreSQL does not use SKIP LOCKED as a fairness scheduler.

This boundary is especially relevant when a queue mixes long transactions with a steady stream of newer work. Non-blocking selection keeps other consumers moving, but progress of the system as a whole is not the same property as eventual service for each individual row.

Any fairness requirement therefore belongs above the lock-skipping primitive. It can be represented through claim expiration, bounded transaction duration, priority policy, separate partitions, or another application-specific mechanism, but it is not implied by the SQL clause itself.

NOWAIT and SKIP LOCKED expose different conflict semantics

NOWAIT is another way to avoid waiting for a row lock, but its observable result is different. When a requested row cannot be locked immediately, NOWAIT reports an error instead of silently moving past that row.

That makes the two modifiers suitable for different interfaces. NOWAIT preserves the significance of encountering a conflicting target: the caller receives failure and can decide what to do next. SKIP LOCKED treats that conflict as a reason to omit the row from the locking result and continue.

For work distribution, omission can be the desired mechanism because another candidate is useful. For operations aimed at a specific row, silently selecting a different target may have no meaningful interpretation, and explicit conflict failure can be the more faithful contract.

The distinction is not merely latency policy. It changes what the caller observes: an error at the conflict boundary versus a result set formed from immediately lockable candidates.

Non-blocking claims deliberately weaken the candidate view

SKIP LOCKED is precise when treated as a contention primitive rather than as a general query accelerator. It permits a locking statement to keep moving when a qualifying row is already claimed by an incompatible locker.

The cost is equally precise. The returned rows no longer represent every visible predicate match that would otherwise be considered. Lock state participates in selection, ordering can be bypassed at contested rows, and fairness is not provided.

Those properties fit concurrent work claiming because temporary omission is often the intended coordination signal. They are a poor fit when the result must describe a complete or consistent set of qualifying rows. The clause trades completeness at the locking boundary for immediate access to other available work.