Locking and Concurrency in PostgreSQL: FOR UPDATE, Deadlocks & pg_locks
Learn how PostgreSQL locks rows with SELECT ... FOR UPDATE, how deadlocks happen and get resolved automatically, and how to inspect active locks with pg_locks.
Locking and Concurrency in PostgreSQL
Locks are how PostgreSQL keeps concurrent transactions from stepping on each other's changes to the same row.
Row-Level Locking: SELECT ... FOR UPDATE
Locks the selected rows so no other transaction can modify them until yours commits or rolls back — essential when you read a value, then write back a change based on it (like decrementing stock).
BEGIN;
SELECT quantity FROM inventory WHERE product_id = 1 FOR UPDATE;
-- other transactions trying to UPDATE or lock this row now WAIT
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
COMMIT;
-- the lock is released; waiting transactions can now proceed
Without FOR UPDATE, two concurrent transactions could both read the same starting quantity, both subtract one, and the final result would be wrong — a classic lost update.
FOR UPDATE SKIP LOCKED
Instead of waiting for a locked row, skip it and grab a different one — the standard pattern for building a job queue where multiple workers pull tasks concurrently without blocking each other.
SELECT * FROM job_queue
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
Deadlocks
A deadlock happens when two transactions each hold a lock the other one needs:
- Transaction A locks row 1, then wants row 2.
- Transaction B locks row 2, then wants row 1.
- Neither can proceed.
PostgreSQL automatically detects this situation and terminates one of the two transactions with a deadlock detected error — your application should catch this and retry.
Prevention tip: always lock rows in the same order across your codebase (e.g. always by ascending id) to make deadlocks far less likely in the first place.
Inspecting Current Locks
SELECT * FROM pg_locks;
SELECT pid, query, state FROM pg_stat_activity WHERE state = 'active';
Useful when a query appears to hang — it's often blocked waiting on a lock held by another session.
Common Errors & Troubleshooting
- A query appears to "hang" with no error — it's very likely waiting on a lock held by another session; check
pg_stat_activityandpg_locks(shown above) to find and, if necessary, terminate the blocking session withSELECT pg_terminate_backend(pid);. ERROR: deadlock detected— PostgreSQL already resolved it by canceling one transaction — catch this specific error in your application and simply retry that transaction.- Using
FOR UPDATEwithoutSKIP LOCKEDin a multi-worker queue — every worker ends up waiting in line for the same locked rows instead of picking up different ones; addSKIP LOCKEDso workers grab whatever's free instead of queueing behind each other.
Key Takeaways
SELECT ... FOR UPDATElocks rows against concurrent modification, protecting classic read-then-write logic from lost updates.FOR UPDATE SKIP LOCKEDis the standard building block for a concurrency-safe job/task queue.- PostgreSQL detects deadlocks automatically and cancels one side — design your app to retry on that error.