PostgreSQL Locking and Concurrency Control
Learn about shared vs exclusive locks, lock escalation, deadlocks, optimistic vs pessimistic concurrency control, and FOR UPDATE clauses.
Locking and Concurrency Control: Shared, Exclusive, Deadlocks, and More
Databases let many users access the same data simultaneously. Locks are the mechanism that makes this safe. Understanding locks helps you write code that is both correct and fast under concurrency.
Introduction
Databases let many users access the same data simultaneously. Without locks, concurrent transactions would overwrite each other’s changes, read each other’s uncommitted work, or read data that never actually persisted. Locks are the mechanism that makes concurrent access safe — and understanding them is the difference between code that works in development and code that fails silently in production under load.
Shared vs Exclusive Locks
PostgreSQL has two main lock modes: shared and exclusive.
Shared Locks
Shared locks allow concurrent reads. Multiple transactions can hold shared locks on the same row at the same time.
-- Transaction 1
SELECT balance FROM accounts WHERE id = 1 LOCK IN SHARE MODE;
-- Transaction 2 can also read
SELECT balance FROM accounts WHERE id = 1 LOCK IN SHARE MODE;
-- Success: both can read simultaneously
Exclusive Locks
Exclusive locks block both shared and exclusive locks. Only one transaction can hold an exclusive lock at a time.
-- Transaction 1
UPDATE accounts SET balance = 900 WHERE id = 1;
-- Acquires exclusive lock
-- Transaction 2 tries to read
SELECT balance FROM accounts WHERE id = 1;
-- Waits...
-- Transaction 1 commits, then Transaction 2 proceeds
FOR SHARE vs FOR UPDATE
SELECT ... FOR SHARE acquires a shared lock. SELECT ... FOR UPDATE acquires an exclusive lock.
-- Shared lock: others can read but not update
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
-- Exclusive lock: blocks everyone else
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
Use FOR SHARE when you need to read data and ensure it doesn’t change while you’re reading. Use FOR UPDATE when you need to modify data.
Lock Escalation
Some databases have a concept called lock escalation, where the system converts many row-level locks into a table-level lock when too many rows are locked.
PostgreSQL does NOT have lock escalation. You can hold locks on millions of rows if needed, though this consumes memory and impacts performance.
Other databases like SQL Server do have lock escalation, which can cause blocking problems when many rows are locked simultaneously.
-- PostgreSQL: no lock escalation
-- This is fine even with many rows
UPDATE orders SET processed = true WHERE status = 'pending';
-- Holds row-level locks, not escalated to table lock
Deadlocks
flowchart LR
subgraph T1["Transaction A"]
A1["Locks Row 1<br/>(UPDATE orders)"]
A2["Waits for Row 2<br/>(UPDATE accounts)"]
end
subgraph T2["Transaction B"]
B1["Locks Row 2<br/>(UPDATE accounts)"]
B2["Waits for Row 1<br/>(UPDATE orders)"]
end
A1 --> A2
B1 --> B2
A2 -.->|"Row 2 held by B"| B1
B2 -.->|"Row 1 held by A"| A1
A deadlock happens when two or more transactions are waiting for each other to release locks. Neither can proceed. PostgreSQL detects this automatically and rolls back one transaction.
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Locks row 1
-- Transaction 2 (concurrent)
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Locks row 2
-- Transaction 1
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Waits for row 2
-- Transaction 2
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Deadlock! Transaction 1 holds row 1
PostgreSQL detects deadlocks automatically and resolves them by rolling back one transaction.
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 67891.
Process 67891 waits for ShareLock on transaction 67890; blocked by process 12345.
HINT: See the server log for query details.
Preventing Deadlocks
The best deadlock is the one you prevent. Three rules help:
- Always lock rows in the same order. If Transaction A always locks row 1 before row 2, and Transaction B does the same, deadlocks cannot occur.
-- Always lock accounts in id order
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
-- ...
-
Keep transactions short. Long transactions hold locks longer and increase deadlock probability.
-
Access shared objects last. In transaction-intensive workloads, access shared lookup tables after modifying user data.
Handling Deadlocks in Application Code
Your application should be prepared to retry on deadlock errors:
def transfer_funds(from_account, to_account, amount):
max_retries = 3
for attempt in range(max_retries):
try:
with connection.cursor() as cursor:
cursor.execute("BEGIN")
cursor.execute(
"SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
[from_account]
)
cursor.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
[amount, from_account]
)
cursor.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
[amount, to_account]
)
cursor.execute("COMMIT")
return True
except DeadlockError:
cursor.execute("ROLLBACK")
continue
return False
Optimistic vs Pessimistic Concurrency Control
There are two philosophical approaches to concurrency control.
Pessimistic Concurrency Control
Assume conflicts will happen and prevent them with locks.
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- Other transactions must wait
Pessimistic locking guarantees no conflicts. It’s simple to reason about and works well under high contention. The downside is that locks block other transactions, which can cause waiting and deadlocks, and reduces throughput under low contention.
Optimistic Concurrency Control
Assume conflicts are rare, detect them when they occur, and retry.
-- Read current version
SELECT balance, version FROM accounts WHERE id = 1;
-- balance=1000, version=5
-- Update with version check
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;
-- If version changed, update affects 0 rows -> retry
Optimistic locking has no locks and therefore higher throughput under low contention, and avoids deadlocks. The downside is conflicts cause retries and wasted work if conflicts are frequent, and the application logic is more complex.
When to Use Each
Pessimistic is better when contention is high, conflicts are expensive (long transactions), or you need guaranteed ordering. Optimistic is better when contention is low, conflicts are cheap (short transactions), or you want maximum throughput.
Optimistic vs Pessimistic: Trade-off Summary
| Aspect | Pessimistic (FOR UPDATE) | Optimistic (version/SNAPSHOT) |
|---|---|---|
| Contention model | Locks block other txns | Conflicts detected at commit |
| Throughput under low contention | Lower (locks have overhead) | Higher (no locks) |
| Throughput under high contention | Lower (lock contention) | Much lower (many retries) |
| Transaction rollback cost | Low (blocked, not rolled back) | High (entire txn rolls back) |
| Complexity in app code | Lower (simple BEGIN/COMMIT) | Higher (retry loop required) |
| Risk of deadlocks | Higher | None (no locks) |
| Best for | High contention, long transactions | Low contention, short transactions |
Row-Level vs Page-Level vs Table-Level Locks
PostgreSQL locks at the row level by default, but can escalate to page-level and table-level locks internally.
Lock Hierarchy
From least to most restrictive:
- Row share — acquired by SELECT FOR SHARE
- Row exclusive — acquired by UPDATE, DELETE, INSERT
- Share update exclusive — acquired by VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY
- Share — acquired by CREATE INDEX (non-concurrent)
- Share row exclusive — acquired by CREATE TRIGGER
- Exclusive — acquired by REFRESH MATERIALIZED VIEW CONCURRENTLY
- Access exclusive — acquired by ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL
-- This acquires row-exclusive lock (fine)
UPDATE accounts SET balance = 900 WHERE id = 1;
-- This acquires access exclusive lock (blocks everything)
ALTER TABLE accounts ADD COLUMN new_col TEXT;
Lock Mode Compatibility
| Requested \ Held | Row Share | Row Excl | Share | Share Row Excl | Excl | Access Excl |
|---|---|---|---|---|---|---|
| Row Share | Yes | Yes | Yes | Yes | No | No |
| Row Excl | Yes | No | No | No | No | No |
| Share | Yes | No | Yes | No | No | No |
| Share Row Excl | Yes | No | No | No | No | No |
| Exclusive | No | No | No | No | No | No |
| Access Excl | No | No | No | No | No | No |
Advisory Locks
PostgreSQL provides advisory locks for application-level locking needs.
When to Use Advisory Locks
Use advisory locks when you need to coordinate work that doesn’t map naturally to table locks. Common uses are preventing duplicate batch processing, rate limiting, and resource reservation.
-- Acquire advisory lock
SELECT pg_advisory_lock(12345);
-- Do work
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Release
SELECT pg_advisory_unlock(12345);
Advisory Lock Functions
pg_advisory_lock(key)— exclusive advisory lockpg_advisory_lock_shared(key)— shared advisory lockpg_try_advisory_lock(key)— non-blocking exclusive lockpg_try_advisory_lock_shared(key)— non-blocking shared lockpg_advisory_unlock(key)— release exclusive lockpg_advisory_unlock_all()— release all locks held by session
-- Non-blocking: returns true if acquired, false if not
SELECT pg_try_advisory_lock(12345);
-- Returns: true (or false if someone else holds it)
Monitoring Locks
Viewing Active Locks
SELECT
l.locktype,
l.relation::regclass,
l.mode,
l.granted,
l.virtualxid,
l.transactionid,
p.pid,
p.usename,
p.query
FROM pg_locks l
JOIN pg_stat_activity p ON l.pid = p.pid
WHERE NOT l.pid = pg_backend_pid();
Viewing Lock Waits
SELECT
p.pid,
p.usename,
p.query,
l.locktype,
l.mode
FROM pg_locks l
JOIN pg_stat_activity p ON l.pid = p.pid
WHERE NOT l.granted
AND NOT l.pid = pg_backend_pid();
Killing a Blocking Process
-- Get the pid from the queries above
SELECT pg_terminate_backend(12345);
-- This rolls back the transaction and releases locks
Real-World Case Study: Amazon DynamoDB’s Lock-Free Design
Amazon DynamoDB chose lock-free concurrency for its distributed key-value store. The reasoning: at massive scale, managing locks across distributed nodes is expensive and creates single points of failure. Traditional database locks require a coordinator to manage shared state — that coordinator becomes a bottleneck and a fragility point.
DynamoDB uses optimistic concurrency control instead. Each item has a Version attribute (or you can use conditional writes with EXPECT conditions). When you update an item, you specify the expected current state. If the condition fails (another write committed first), DynamoDB returns a ConditionalCheckFailedException. Your application decides whether to retry.
This design choice shows up in their API directly:
response = table.update_item(
Key={'pk': 'order123'},
UpdateExpression='SET total = :new_total',
ConditionExpression='total = :expected_total', # optimistic lock
ExpressionAttributeValues={
':new_total': 150.00,
':expected_total': 100.00 # if this changed, fail
}
)
The consequence: DynamoDB can scale horizontally without lock coordination overhead, and it never blocks a writer waiting for another writer. The tradeoff is that high-contention items (multiple writes to the same key simultaneously) generate many failed conditional writes and require application-level retries. For most real-world workloads this works well — contention is rare. For write-heavy hot keys, you need a separate strategy (write sharding, or accepting last-writer-wins semantics).
The lesson applies to PostgreSQL: optimistic locking via version columns gives you similar benefits in a single-node or primary-replica setup — no lock manager overhead, no blocking, and horizontal read scalability via MVCC. The cost is retry logic in your application and wasted work when conflicts are frequent.
Common Production Failures
Deadlock victim retry not implemented: Your code does a fund transfer with FOR UPDATE but does not retry on deadlock errors. When PostgreSQL rolls back a deadlock victim, the application throws an exception and the transfer is lost. Every write transaction using FOR UPDATE in a concurrent workload needs a retry loop — deadlocks are not a sign of bad code, they are an inevitable consequence of concurrent locking.
Long-running transactions holding locks: A report query runs inside a transaction, holds locks for 10 minutes while generating a PDF, then commits. During that time, every concurrent update to the rows it read is blocked. The fix is to use READ COMMITTED and read rows without locks, or to ensure transactions are as short as possible.
Optimistic locking without retry logic: You implement version-based optimistic concurrency but only check if the update succeeded once — you don’t retry. Concurrent updates fail silently (0 rows affected) and the user sees no feedback. You need a retry loop that re-reads the row, increments the version, and tries again.
FOR UPDATE on a scan that returns many rows: You write SELECT * FROM orders WHERE status = 'pending' FOR UPDATE expecting to lock just a few rows. If the table has millions of pending orders, this escalates to a table-level lock and blocks all concurrent access to the orders table. Lock only the specific rows you need.
SELECT FOR SHARE blocking writes: On PostgreSQL, SELECT ... FOR SHARE holds a row-level share lock. Other transactions can read but cannot update. If your “read-only” report runs with FOR SHARE and the table has frequent updates, your report blocks writers. Use plain SELECT in READ COMMITTED mode unless you genuinely need to prevent concurrent updates.
Advisory lock not released on error: You acquire pg_advisory_lock but an exception occurs before pg_advisory_unlock. The lock is held until the session ends. If your connection pool recycles connections without resetting state, you can leak advisory locks across requests. Always use pg_try_advisory_lock in a BEGIN/ROLLBACK block so the lock is released on error.
Quick Recap Checklist
- Shared locks allow concurrent reads; exclusive locks block both reads and writes
- PostgreSQL has no lock escalation — row locks remain row-level
- Deadlocks are resolved by PostgreSQL rolling back one transaction automatically
- Prevent deadlocks by locking rows in consistent order across transactions
- Pessimistic locking blocks conflicts; optimistic locking detects them at commit
- FOR SHARE acquires shared lock; FOR UPDATE acquires exclusive lock
- Advisory locks are session-scoped, not transaction-scoped
- Always use retry logic for serialization failures in application code
- Long-running transactions hold snapshot and prevent vacuum cleanup
- Connection pools must satisfy: pool_size × instances <= max_connections
Common Problems and Fixes
| Problem | Fix |
|---|---|
| Deadlock victim not retried | Add retry loop with exponential backoff in application |
| Long-running txn holding row locks | Use READ COMMITTED, keep transactions short |
| Optimistic locking without retry | Implement retry loop on 0 rows affected |
| FOR UPDATE on large scan | Use cursor with LIMIT, batch in separate transactions |
| SELECT FOR SHARE blocking writers | Use plain SELECT unless you need to prevent updates |
| Advisory lock leak on error | Use pg_try_advisory_lock in BEGIN/ROLLBACK block |
Interview Questions
Under READ COMMITTED, both transactions read 1000, both compute 1100 (for example, adding 100), and both write — the second write overwrites the first. Transaction A's update is lost. This is the "lost update" problem. To fix it, use SELECT ... FOR UPDATE in Transaction A before reading — this acquires an exclusive lock on the row, and Transaction B's read blocks until Transaction A commits or rolls back. Transaction B then reads the committed value (1000 or whatever A wrote) and computes correctly. Alternatively, use SERIALIZABLE, which detects the write-write conflict and rolls back one transaction.
SELECT ... FOR UPDATE to lock rows during a batch job that processes 50,000 orders. The deployment hits production and suddenly the entire orders table is blocked for 30 seconds. What happened?The FOR UPDATE on a scan that matches many rows — SELECT * FROM orders WHERE status = 'pending' FOR UPDATE — acquires row-level locks on every matching row. With 50,000 pending orders, that means 50,000 row locks. While the transaction holds these locks, any other transaction trying to access those rows (or in some cases, the whole table) waits. The fix is to lock rows in small batches, or lock only the specific rows needed using a cursor with LIMIT, processing chunks in separate transactions.
Pessimistic locking assumes conflicts will happen and prevents them by acquiring locks before working. Optimistic locking assumes conflicts are rare and detects them at commit time. Choose pessimistic when contention is high (locks prevent wasted work from retries), when transactions are long (a retry after a long-running transaction wastes significant time), or when you need guaranteed ordering. Choose optimistic when contention is low (retries are rare), when transactions are short (retry cost is negligible), or when you want maximum throughput and can tolerate occasional failures. Most web applications with short transactions and moderate contention benefit from optimistic locking — but if you are processing financial transfers with high concurrency, pessimistic with explicit row locks is safer.
Advisory locks acquired with pg_advisory_lock are released when the session ends, not when a transaction commits. If your application holds an advisory lock inside a transaction and then an error occurs before calling pg_advisory_unlock, the lock stays held for the lifetime of the database session. If your connection pool recycles sessions without resetting the session state — or if the application server process is killed and restarted without closing connections cleanly — you can accumulate leaked advisory locks over time. New requests trying to acquire the same advisory lock will block waiting for a lock that nobody is using. The fix is to always acquire advisory locks inside a BEGIN/ROLLBACK block with pg_try_advisory_lock, or use advisory locks with ON ROLLBACK RELEASE semantics if your version supports it.
The transaction is holding locks longer than necessary. The report query runs inside a transaction and reads rows with SELECT ... FOR UPDATE or just a plain SELECT, but because the transaction stays open for 10 minutes, all writers who need those rows wait. The fix is to use READ COMMITTED isolation level and read without locks — the report does not need to prevent concurrent updates since it only needs a point-in-time snapshot of committed data. Alternatively, ensure transactions are as short as possible, or use a snapshot isolation approach that does not hold locks for read-only operations.
With optimistic locking, when two transactions read the same row and both try to update it, one succeeds and the other fails because the version changed. The failed transaction gets 0 rows affected. If your application checks for success but does not retry, the user's update silently disappears — they think their change was saved but it was not. You need a retry loop: re-read the row, increment the version, apply the change, and try again. Without retry logic, optimistic locking provides no feedback when conflicts occur.
This is a classic deadlock scenario. Transaction A holds a shared lock and wants exclusive. Transaction B holds a shared lock and wants exclusive. Neither can upgrade to exclusive because the other holds a shared lock — shared locks are not compatible with each other when another transaction wants exclusive. PostgreSQL does not support lock upgrade (a shared lock cannot be upgraded to exclusive in-place). Both transactions are now blocked waiting for the other to release. PostgreSQL detects this deadlock after a short wait and rolls back one transaction with a serialization error. The fix is to always acquire exclusive locks first if you know you will need them, or use SELECT ... FOR UPDATE from the start if you know the transaction will modify rows.
A single transaction locking 5 million rows holds enormous lock pressure and creates a massive write-ahead log footprint. If the transaction fails midway, the rollback takes as long as the original execution. Long-running transactions also prevent autovacuum from cleaning dead tuples, causing bloat. The chunked approach: use LIMIT with an ORDER BY id in a cursor, processing, say, 10,000 rows per transaction. Each chunk commits quickly, releases locks, and allows vacuum to clean up. The trade-off is you need idempotency in case a chunk fails and you need to restart — the processed chunks should not be re-processed.
BEGIN; SELECT ... FOR UPDATE; UPDATE ...; COMMIT;. Under what conditions does the FOR UPDATE lock get released before COMMIT?The FOR UPDATE lock is held until the transaction ends — either COMMIT or ROLLBACK. It is not released after the SELECT, only after the entire transaction completes. This means other transactions trying to access those rows with FOR UPDATE or any write operation will block for the entire duration of your transaction. Within your transaction, the SELECT ... FOR UPDATE acquires the lock, the UPDATE uses it, and only at COMMIT (or ROLLBACK) are the locks released. If you need to release locks earlier within a transaction, you would need to use SAVEPOINTs and ROLLBACK TO SAVEPOINT, but this is rarely the right approach — design transactions to be short instead.
For distributed locking across PostgreSQL instances, you need an external coordinator. Options include: database-backed locks using a dedicated locking table with SELECT FOR UPDATE on a lock row (simple, works across read replicas), Redis-based distributed locks (Redlock algorithm, fast but requires Redis infrastructure), and consensus systems like etcd or ZooKeeper which provide distributed coordination with strong consistency guarantees. PostgreSQL advisory locks cannot span instances — a lock held on primary A is invisible to primary B. The trade-off is always between consistency, availability, and partition tolerance as described by the CAP theorem.
max_connections = 100 but after deploying 20 application instances with pool_size=10 each, you start getting connection errors. The math says 20 x 10 = 200 but max_connections is 100. What happens and how do you solve it?The math fails: 20 x 10 = 200 required backend connections but PostgreSQL only allows 100. Each application instance holds 10 connections, but with 20 instances all wanting connections simultaneously, you need 200. The solution is pgBouncer in transaction mode deployed as a sidecar to each application instance. Each application connects to its local pgBouncer (10 connections per pgBouncer), and all pgBouncers multiplex down to a small number of actual PostgreSQL backend connections (say, 20-30). This breaks the N x pool_size dependency on max_connections. Without pgBouncer, you either need to reduce pool_size, reduce instance count, or increase max_connections (but increasing max_connections increases memory pressure on PostgreSQL).
wait_event = Lock for a specific table. What queries do you run to identify which sessions are waiting and which are holding the lock?Run this query to find blocked and blocking sessions:
SELECT
l.locktype,
l.relation::regclass,
l.mode,
l.granted,
p.pid,
p.usename,
p.query,
l.transactionid
FROM pg_locks l
JOIN pg_stat_activity p ON l.pid = p.pid
WHERE NOT l.pid = pg_backend_pid()
ORDER BY l.granted;
Then to kill the blocking process: SELECT pg_terminate_backend(pid). To find which locks a specific session holds, query pg_locks filtered by the pid. The blocking chain can be deep — session A blocks B, B blocks C, C blocks D. Use pg_blocking_pids(pid) (PostgreSQL 13+) to directly identify blockers without joining manually.
The lock is automatically released when the database connection terminates — PostgreSQL rolls back any uncommitted transactions. When the application process dies without calling ROLLBACK, the TCP connection closes and PostgreSQL detects the session loss, then performs cleanup including releasing all locks held by that session. This is why connection poolers can safely recycle connections after application crashes — the database guarantees lock cleanup.
pg_advisory_lock and pg_try_advisory_lock? When would you use each?pg_advisory_lock blocks until the lock is acquired — if another session holds the lock, you wait. pg_try_advisory_lock returns true immediately if the lock is acquired, false if it is held by another session (non-blocking). Use pg_advisory_lock when you must have the lock and can afford to wait. Use pg_try_advisory_lock when you want to attempt the lock without blocking — for example, to check if a background job is already running before starting it.
INSERT acquires a row-exclusive lock (RowExclusiveLock) on the table. Multiple concurrent INSERTs can proceed because row-exclusive locks are compatible with each other — they only conflict with operations that need access exclusive mode (like ALTER TABLE). Under heavy INSERT concurrency, you should also consider table-level lock waits for operations like CREATE INDEX CONCURRENTLY or VACUUM — these require AccessExclusiveLock and block INSERTs.
TRUNCATE a table that has uncommitted changes from another transaction?TRUNCATE requires an AccessExclusiveLock which blocks all concurrent access — including other transactions reads and writes. If another transaction is running on the table, TRUNCATE waits. If the other transaction is uncommitted and holding a lock, TRUNCATE will wait for it to commit or rollback. TRUNCATE is not recommended on tables with long-running active transactions because it blocks all access until the lock is acquired.
A plain SELECT acquires only a share lock (RowShareLock) which is compatible with other reads and writes — it should not block. However, SELECT can block if it is in a transaction using SELECT ... FOR SHARE — this acquires a stronger lock that conflicts with writers. Plain SELECT also blocks when the table has pending lock requests in the queue — if an AccessExclusiveLock request (from TRUNCATE, ALTER TABLE) is waiting, subsequent SELECTs queue behind it. Use pg_blocking_pids() to identify what is blocking.
Zero rows affected means the WHERE clause in the UPDATE did not match any rows — specifically, the version column no longer matched the expected value. Another transaction updated the row between your read and your write. This is the expected behavior of optimistic locking — it detects the conflict. The application must re-read the row (now with the new version), recompute the update, and retry. If your code is treating 0 rows affected as success, it is silently losing updates. Always check the returned row count and retry on zero rows affected.
lock_timeout is the maximum time to wait for a lock — if a lock cannot be acquired within the timeout, the transaction is aborted with a lock timeout error. statement_timeout is the maximum time a statement can run before being cancelled. Use lock_timeout to prevent deadlocks from becoming indefinite waits — if you cannot acquire a lock within 2 seconds, abort and retry. statement_timeout cancels long-running queries regardless of locks. Set both: lock_timeout for transactional integrity, statement_timeout for runaway query protection.
SELECT ... FOR UPDATE NOWAIT and gets an error immediately instead of waiting. What happened?NOWAIT causes the query to fail immediately with an error if the row is already locked by another transaction, instead of waiting. The error means the row (or page) is held by another session. Without NOWAIT, the query would block waiting for the lock. This is useful when you want immediate feedback rather than waiting — the application can then retry, try a different row, or notify the user. The error is not a deadlock — deadlock detection only applies when two transactions are waiting on each other circularly.
Further Reading
- PostgreSQL Explicit Locking documentation — complete reference for all lock modes and compatibility matrix
- PostgreSQL Advisory Lock functions — advisory lock API reference
- MVCC: How PostgreSQL Implements Concurrent Access — internal MVCC mechanics and visibility rules
- pg_locks view documentation — monitoring locks via system catalog
- PostgreSQL transaction isolation levels — how isolation relates to locking behavior
Conclusion
Locks are how databases make concurrent access safe. Shared locks allow readers to coexist; exclusive locks serialize writers. Deadlocks happen when transactions hold locks in inconsistent orders, but you can prevent them with consistent locking order and short transactions. Choose pessimistic locking when contention is high, optimistic when it’s low. PostgreSQL’s row-level locking avoids the escalation problems of other databases, and advisory locks give you flexibility for application-level coordination.
For more on keeping transactions safe, see Transaction Isolation and Relational Databases.
Category
Related Posts
Isolation Levels: READ COMMITTED Through SERIALIZABLE
Understand READ COMMITTED, REPEATABLE READ, and SERIALIZABLE isolation levels, read vs write anomalies, and SET TRANSACTION syntax.
Database Capacity Planning: A Practical Guide
Plan for growth before you hit walls. This guide covers growth forecasting, compute and storage sizing, IOPS requirements, and cloud vs on-prem decisions.
Connection Pooling: HikariCP, pgBouncer, and ProxySQL
Learn connection pool sizing, HikariCP, pgBouncer, and ProxySQL, timeout settings, idle management, and when pooling helps or hurts performance.