The Database Is Not a Message Queue (Until It Has to Be)
Why SELECT FOR UPDATE SKIP LOCKED gets you further than you'd expect, and where it stops
Every few months I watch a team reach for a message queue to solve a problem that's really just "process these rows one at a time, and don't process the same row twice." SQS, RabbitMQ, a Redis-backed job library — all reasonable tools, all adding a new piece of infrastructure with its own failure modes, its own monitoring, its own local-dev story. Before any of that, I want to know if Postgres can just do it.
Usually it can.
The naive version, and why it breaks
The instinct is to add a status column — pending, processing, done — and have a worker poll for pending rows, flip them to processing, do the work, flip them to done. It works fine with one worker. The moment you run two workers, or one worker with two threads, you get the classic race: both read the same pending row before either has updated it, and now you're sending the same email twice or charging a card twice.
The fix people reach for first is usually an application-level lock, or a separate lock table, or "just don't run more than one worker." All of those work until they don't. The real fix is one line of SQL that's existed since Postgres 9.5:
SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 10 FOR UPDATE SKIP LOCKED;
FOR UPDATE locks the rows you select, inside the transaction, until you commit or roll back. SKIP LOCKED tells Postgres to just skip any row another transaction already has locked instead of waiting on it or erroring out. Two workers running that query at the same time will grab disjoint sets of rows. No polling gap, no separate coordination layer, no distributed lock. The database was already doing row-level locking for you — this just tells it not to make you wait for someone else's lock.
That's the whole trick. Combined with a transaction that updates the row's status before you commit, you get exactly-once processing with the same durability guarantees as the rest of your data, because it is the rest of your data.
Where this is genuinely fine
For most background job workloads — sending an email, generating a report, syncing a record to a third-party API, running a scheduled cleanup — this pattern is not a compromise. It's arguably better than a separate queue, for one reason people underrate: your job data lives in the same transaction as the business data that created it. You can insert the row that needs to be emailed and the job to email it in one commit. No dual-write problem, no "the queue got the message but the database transaction rolled back" inconsistency. That class of bug simply doesn't exist here.
It also means you get SQL for free where a queue gives you almost nothing. Want to know how many jobs failed in the last hour, grouped by type? SELECT ... GROUP BY. Want to manually requeue one specific stuck job because a client called about it? UPDATE jobs SET status = 'pending' WHERE id = .... Try doing either of those cleanly against SQS.
For a lot of the client work I do — SaaS products with dozens to low thousands of background jobs a day, not millions — this is the entire answer. No new service to provision, no new credentials to rotate, no new thing to explain to a client six months later when they ask what's running their infrastructure.
Where it bites
The failure modes show up as scale and requirements change, and they're worth naming honestly rather than discovering them in production.
Lock contention under high throughput. SKIP LOCKED avoids blocking, but every poll is still a query against a live table, and every worker cycle touches rows that are also being written by whatever's inserting new jobs. At low volume this is noise. At high volume, with many workers polling frequently, you start paying real overhead just to find work — and it competes with the actual application traffic hitting the same table.
Table bloat and vacuum pressure. Jobs get inserted, updated at least once (maybe twice — processing, then done), and eventually need to be cleaned up. That's a lot of dead tuples for a table that's conceptually "temporary" data. If nobody's pruning completed jobs, you end up with a jobs table that's larger than every real business table in the schema, and autovacuum working harder than it should on data nobody needs six months later.
Missing real queue semantics. Delayed execution, priority ordering, backpressure, dead-letter handling after N retries, fan-out to multiple consumer groups — these are all things a real queue gives you as first-class features. You can build approximations in Postgres (a run_after timestamp column, a priority integer, a retry_count), and I've done exactly that more than once. But you're reimplementing queue semantics one column at a time, and at some point the accumulated columns and cron jobs are more complex than just running RabbitMQ.
Cross-service decoupling. If the producer and consumer are genuinely different services owned by different teams, a shared database table is a much tighter coupling than either side probably wants. A queue gives you a clean contract; a shared table gives you a shared table.
The actual decision rule
I don't start a project by asking "queue or database." I start with the database, because it's already there, and I let a specific, named pain point be the trigger to change — not a hunch that it "won't scale." If lock contention shows up in query stats, if the jobs table needs its own vacuum strategy, if I actually need delayed retries with backoff — those are real signals. Anticipating them before they exist just means maintaining infrastructure for a problem you don't have yet.
Boring technology that's already running beats exciting technology you have to learn, deploy, and monitor, right up until the moment it genuinely can't do the job. SKIP LOCKED buys you a lot of runway before that moment arrives.