Before you add Kafka, RabbitMQ, or SQS to pull work off a queue, check whether you already have a queue: a table with a “pending” flag. FOR UPDATE SKIP LOCKED turns it into a concurrent one.
The pattern
SELECT id, payload
FROM work_items
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
FOR UPDATElocks the selected rows for the duration of the transaction. No one else can claim them.SKIP LOCKEDtells other workers running the same query to skip rows that are already locked and grab the next available ones.
Run this in N parallel workers and they partition the work between themselves with zero coordination. Worker A locks the first 100, worker B skips those and locks the next 100, and a worker that finds nothing left simply gets an empty result and exits.
Why it works without a coordinator
Traditional queue systems exist to solve “two workers must not process the same item.” SKIP LOCKED solves it at the database level: a locked row is invisible to the claim query of every other transaction. There’s no leader, no lease, no distributed lock. The row lock is the lease, and it’s released automatically when the transaction commits or the worker dies.
When to use it (and when not)
Use it when your throughput is “thousands to low-millions of items per day,” the work already lives in your database, and you’d rather not operate a second system. This covers a huge range of background jobs, ETL batches, and outbox processing.
Reach for a real broker when you need millions of messages per second, fan-out to many independent consumers, cross-datacenter durability, or pub/sub semantics. SKIP LOCKED is a work queue, not a message bus.
Takeaway
A “pending” column plus FOR UPDATE SKIP LOCKED is a complete, safe, multi-worker queue with no extra infrastructure. It’s one of the highest-leverage clauses in PostgreSQL, and most teams reach past it for a broker they don’t yet need.
