PostgreSQL Locking: Best Practices for Long Transactions

PostgreSQL locking is all about managing how data is accessed when multiple users or processes interact with the database at the same time. The system uses locks to ensure data integrity and prevent conflicts, especially during updates, schema changes, or maintenance tasks. But long-running transactions can cause performance issues by holding locks for too long, blocking other operations, and creating bottlenecks.
Key takeaways:
- Keep transactions short: Avoid holding locks longer than necessary. Handle non-database tasks (e.g., calculations, user input) outside transactions.
- Understand lock types: Use appropriate lock modes (e.g., row-level or table-level) to minimise contention.
- Prevent deadlocks: Access objects in a consistent order and implement retry logic for deadlock errors.
- Set timeouts: Use
lock_timeoutordeadlock_timeoutto avoid indefinite waits. - Monitor locks: Use tools like
pg_locksandpg_stat_activityto identify and resolve lock issues. - Advanced tools: Use advisory locks for application-level coordination or
SKIP LOCKEDfor processing rows without blocking.
Lock Types and Their Effects

PostgreSQL Lock Types: Row-Level vs Table-Level Modes Comparison
PostgreSQL offers various lock types, each influencing transaction behaviour differently. Understanding these distinctions is crucial for designing efficient locking strategies, particularly for long-running transactions. Below, we explore the differences between row-level and table-level locks, the specific modes available, and how they impact performance.
Row-Level Locks vs Table-Level Locks
Row-level locks target individual records, ensuring that only the locked rows are affected. This means other operations, like standard SELECT queries, continue unhindered. These locks are stored within the data tuple (xmax field), and PostgreSQL imposes no architectural limit on the number of locked rows. Commands like UPDATE, DELETE, and SELECT FOR UPDATE automatically apply row-level locks to the relevant rows.
Table-level locks, on the other hand, apply to entire tables and are typically used during schema changes or maintenance tasks. These locks range in restrictiveness, from ACCESS SHARE to ACCESS EXCLUSIVE. Despite some potentially confusing names, such as “Row Exclusive”, table-level locks are managed in the database server’s memory. For example:
ACCESS SHARE: Used by simpleSELECTstatements, allowing concurrent reads.ACCESS EXCLUSIVE: The strictest mode, required for operations likeDROP TABLE,TRUNCATE, andVACUUM FULL, which block all access, including reads.
Unlike some other database systems, PostgreSQL does not use lock escalation for standard row-level locks. This means it avoids converting multiple row locks into a single table lock, enabling high levels of concurrency even when thousands of rows are locked simultaneously.
PostgreSQL Lock Modes

PostgreSQL supports four row-level lock modes, each with a different level of restrictiveness:
- FOR KEY SHARE: The least restrictive lock, used by
SELECT FOR KEY SHARE. It prevents changes to keys but allows most other operations. - FOR SHARE: Acquired by
SELECT FOR SHARE, this mode blocks modifications that could interfere with shared reads. - FOR NO KEY UPDATE: Applied when updating non-key columns, it ensures key integrity is not compromised.
- FOR UPDATE: The most restrictive row-level lock, used for operations like
DELETEor updates to key columns. It blocks all other row-level locks.
At the table level, locks are similarly varied. For instance, a simple SELECT acquires an ACCESS SHARE lock, while UPDATE or DELETE commands acquire a ROW EXCLUSIVE lock. A ROW EXCLUSIVE lock conflicts with more restrictive modes like SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, and ACCESS EXCLUSIVE.
| Row-Level Mode | Acquired By | Conflicts With |
|---|---|---|
| FOR KEY SHARE | SELECT FOR KEY SHARE | FOR UPDATE |
| FOR SHARE | SELECT FOR SHARE | FOR NO KEY UPDATE, FOR UPDATE |
| FOR NO KEY UPDATE | UPDATE (non-key columns) | FOR SHARE, FOR NO KEY UPDATE, FOR UPDATE |
| FOR UPDATE | DELETE, UPDATE (key columns) | All other row-level locks |
Lock Escalation and Contention
While PostgreSQL avoids lock escalation for standard locks, predicate locks in SERIALIZABLE transactions can escalate to page- or relation-level locks if certain thresholds are exceeded. For example, the default max_pred_locks_per_page value is 2. If more than two rows on a single page are predicate-locked, the lock may extend to cover the entire page.
Long-running transactions can exacerbate contention by holding locks until they are either committed or rolled back. Even a relatively simple ROW EXCLUSIVE lock from an UPDATE can block maintenance operations requiring an ACCESS EXCLUSIVE lock, such as VACUUM FULL or CLUSTER. Additionally, the default max_locks_per_transaction value is 64, limiting the number of distinct object locks a transaction can acquire without affecting row-level locks.
Managing Locks in Long Transactions
Lock management becomes crucial in long-running transactions, as PostgreSQL holds locks until the transaction either commits or rolls back. The longer a transaction remains active, the higher the likelihood of blocking other sessions and impacting application performance. However, adopting a few practical strategies can help minimise these challenges.
Keep Transactions Short
One of the easiest ways to reduce lock contention is by keeping transactions as short as possible. Since locks are held until a COMMIT or ROLLBACK, you should limit transactions to only the necessary atomic operations. Tasks like calculations, API calls, or file processing should be handled outside of transactions.
When dealing with multiple lock modes on the same object, always request the most restrictive mode first to avoid deadlocks and unnecessary lock upgrades. For situations where immediate access is required, the NOWAIT clause with the LOCK command can be used to abort the transaction if the lock isn’t available, rather than waiting indefinitely. By keeping transactions brief, you directly reduce the likelihood of extensive lock contention.
Access Objects in a Consistent Order
Deadlocks occur when transactions hold locks that other transactions need, creating a circular dependency. The PostgreSQL documentation advises:
“The best defence against deadlocks is generally to avoid them by being certain that all applications using a database acquire locks on multiple objects in a consistent order”.
For instance, if every transaction accesses Table A before Table B, later transactions will simply wait for earlier ones to finish instead of causing a deadlock.
This principle also applies to row-level locks. When updating multiple rows, always process them in a consistent order, such as sorting by a unique identifier like the primary key. PostgreSQL expert Laurenz Albe highlights:
“Applications that have a lot of concurrent DML typically have very small transactions that affect very few rows, and they make sure to lock rows in a certain order”.
Deadlocks are temporary conflicts, so it’s wise to design your application to catch deadlock exceptions and retry the transaction automatically. By maintaining a consistent access order, you can avoid the circular waits that lead to deadlocks.
Choose the Right Isolation Level
PostgreSQL’s default Read Committed isolation level is suitable for most applications. It ensures each query sees only data committed before the query begins, offering a balance between simplicity and performance. As the documentation notes:
“The partial transaction isolation provided by Read Committed mode is adequate for many applications, and this mode is fast and simple to use”.
For tasks requiring stable multi-query views, you can use Repeatable Read, which maintains a consistent snapshot throughout the transaction. This mode handles serialisation failures gracefully. If you opt for Serializable, consider declaring transactions as READ ONLY and setting idle_in_transaction_session_timeout to disconnect idle sessions.
Advanced Lock Management Techniques
When basic lock management falls short, PostgreSQL offers advanced features designed to handle high-concurrency environments with greater precision. These tools build on standard practices to further minimise contention in complex setups.
Use Advisory Locks
Advisory locks in PostgreSQL allow your application to coordinate tasks – like ensuring only one background worker processes a job at a time – without relying on standard MVCC locks. Unlike traditional locks, PostgreSQL records advisory locks but doesn’t enforce them, giving you more flexibility. They come in two forms:
- Session-level locks: These persist until explicitly released or the session ends.
- Transaction-level locks: Managed with
pg_advisory_xact_lock, these are automatically released when the transaction finishes, making them safer for handling failures or rollbacks.
Advisory locks are stored in shared memory, avoiding the disk write overhead and table bloat associated with row-level locking. This design enables PostgreSQL to manage tens to hundreds of thousands of locks, depending on your max_locks_per_transaction and max_connections settings. As the PostgreSQL documentation highlights:
“Advisory locks are faster, avoid table bloat, and are automatically cleaned up by the server at the end of the session”.
When using advisory locks in queries involving LIMIT or ORDER BY, it’s a good idea to wrap the logic in a subquery. This ensures locks are only acquired for rows that are actually processed, preventing unnecessary “dangling” locks. However, keep in mind that transactions running under Repeatable Read isolation might not immediately reflect changes made after releasing an advisory lock, as they maintain their own consistent snapshot. To further optimise, consider setting appropriate timeouts to avoid indefinite waiting.
Set Lock Timeouts
By default, PostgreSQL transactions wait indefinitely for locks to become available, which can create bottlenecks in busy systems. Setting timeouts can help mitigate this issue by preventing lock starvation – situations where a transaction waits endlessly because other transactions keep acquiring compatible locks.
The deadlock_timeout parameter controls how long PostgreSQL waits before checking for deadlocks, with the default set to one second. Ideally, this value should exceed the typical transaction duration to avoid unnecessary checks while still catching genuine issues. Additionally, enabling log_lock_waits ensures that PostgreSQL logs messages about lock waits based on this timeout. For extreme high-throughput scenarios, the SKIP LOCKED clause offers an alternative approach.
Use SKIP LOCKED
The FOR UPDATE SKIP LOCKED clause is a powerful tool for concurrent applications. It allows multiple workers to process rows from the same table without blocking each other. Instead of waiting for locked rows to become available, SKIP LOCKED simply skips over them and returns only the rows that are accessible. This is particularly useful for task queue implementations. For example:
SELECT … FOR UPDATE SKIP LOCKED LIMIT n; This query enables multiple background workers to fetch distinct sets of records simultaneously, maximising throughput. With SKIP LOCKED, you can process available rows immediately without getting stuck, making it ideal for scenarios where strict sequential order isn’t necessary. However, if strict order is required, you’ll need an alternative strategy.
Here’s a quick comparison of lock management options:
| Feature | NOWAIT Clause | SKIP LOCKED Clause | Indefinite Wait (Default) |
|---|---|---|---|
| Behaviour | Errors out immediately if the lock is unavailable | Bypasses locked rows and returns available ones | Transaction waits until the lock is granted or a deadlock is detected |
| Best Use Case | Critical updates that must fail fast if contested | High-concurrency background workers or job queues | Standard operations where eventual completion is acceptable |
| Impact on Throughput | High; prevents blocking but requires application-side retry logic | Very high; maximises concurrency by avoiding contention | Low; can lead to cascading blocking and connection exhaustion |
Monitoring and Fixing Lock Problems
When managing database locks, it’s not just about setting them up efficiently – keeping an eye on them and resolving issues quickly is just as important. Even with the best planning, lock contention can still crop up in production systems. The trick lies in catching these problems early.
Monitor Locks in Real Time
PostgreSQL provides a powerful tool for tracking locks: the pg_locks system view. This view displays a row for every lockable object in the system, whether it’s currently held or being waited on. Pay close attention to the granted column. If it shows false, it means the process is stuck waiting for a lock held by another session.
For more detailed insights, you can link pg_locks with pg_stat_activity via the pid column. This reveals extra details like the SQL query, user, and application name associated with each lock.
Another handy tool is the pg_blocking_pids() function, which quickly identifies the IDs of processes that are blocking a transaction. To pinpoint contention issues, you could run:
SELECT relation::regclass, * FROM pg_locks WHERE NOT granted; This query highlights which tables are causing bottlenecks. Additionally, enabling log_lock_waits in your PostgreSQL configuration ensures that any lock wait exceeding the deadlock_timeout (default: one second) gets logged. This feature is especially useful for identifying sporadic locking problems.
Handle Deadlocks
Once you’ve identified lock waits, the next challenge is dealing with deadlocks. PostgreSQL is designed to detect and resolve deadlocks automatically by terminating one of the transactions involved. However, your application needs to handle these situations smoothly. Implementing retry logic with exponential backoff can help your system recover gracefully instead of outright failing.
If a long-running transaction is holding up critical operations, you can intervene by running:
SELECT pg_terminate_backend(pid); This command forcefully ends the blocking session and releases its locks.
Preventing deadlocks in the first place is, of course, the best approach. A good habit is to maintain a consistent locking order. If a transaction requires a restrictive lock later, acquire it right at the start to avoid conflicts with other transactions attempting similar upgrades. Also, setting lock_timeout = '5s' ensures that transactions don’t wait indefinitely, which can help avoid prolonged blocking issues.
Improve Query Performance to Reduce Lock Pressure
Reducing lock contention doesn’t just resolve immediate issues – it also enhances query performance overall. The longer a query takes, the longer it holds locks, potentially causing delays for others. For example, during schema migrations, setting a low lock_timeout prevents heavy locks from holding up subsequent queries. By keeping queries efficient and quick, you naturally ease the pressure on the system’s locking mechanisms.
Conclusion
Key Takeaways
Managing locks effectively in PostgreSQL is crucial for keeping your database running smoothly. One of the simplest yet most impactful strategies is to keep transactions short – aim for under 30–60 seconds. Longer transactions can lead to the accumulation of dead tuples, resulting in bloat and reduced performance. Another key practice is acquiring locks in a consistent order to avoid deadlocks. Ideally, secure the most restrictive lock first to minimise conflicts during upgrades. As the PostgreSQL documentation advises:
“The best defense against deadlocks is generally to avoid them by being certain that all applications using a database acquire locks on multiple objects in a consistent order.”
Other techniques include using advisory locks for application-level concurrency, setting appropriate lock timeouts, and leveraging SKIP LOCKED for queue-like workloads. Since deadlocks are transient errors, incorporating retry logic in your application code ensures quick recovery. Regularly monitoring your database using tools like pg_locks and pg_stat_activity can help you spot potential issues early. By focusing on concise transactions, consistent lock handling, and advanced locking techniques, you can build a solid foundation for efficient database management.
Building Scalable and Reliable Database Operations
Good lock management isn’t just about avoiding immediate problems – it also lays the groundwork for a scalable database system. In high-concurrency environments, improper lock handling can create bottlenecks, forcing PostgreSQL to perform costly deadlock detection more often. Holding locks for too long can also exhaust the shared memory pool used for locks, which is governed by the max_locks_per_transaction setting (defaulting to 64). When this pool is depleted, database operations can grind to a halt. By implementing these lock management practices, you can enhance both the performance and scalability of your PostgreSQL database.
FAQs
What are the best ways to reduce lock contention in long PostgreSQL transactions?
To reduce lock contention in PostgreSQL, focus on keeping transactions brief so that locks are released promptly. Opt for fine-grained row-level locks rather than broader table locks whenever possible, and steer clear of explicit LOCK statements unless they’re absolutely essential.
Make a habit of monitoring the pg_locks view regularly to spot and address any potential contention issues. By carefully designing transactions and managing lock usage, you can boost database performance and minimise risks like deadlocks.
What is the difference between row-level and table-level locks in PostgreSQL?
Row-level locks, triggered by commands like SELECT FOR UPDATE or SELECT FOR SHARE, are designed to lock only the specific rows involved in a transaction. This approach ensures that other sessions can still access and modify rows that aren’t locked, making it easier to handle multiple operations at the same time.
On the other hand, table-level locks apply to the entire table and come in various modes, such as AccessShareLock, RowExclusiveLock, or AccessExclusiveLock. Depending on the mode, these locks can limit actions like reading, writing, or making structural changes to the table. Table-level locks are often used for tasks requiring broader control, like altering the table’s structure or preventing conflicting changes during a transaction.
What are the best ways to avoid deadlocks in PostgreSQL?
To reduce the chances of deadlocks in PostgreSQL, here are some practical tips to keep in mind:
- Maintain a consistent lock order: Always acquire locks in the same sequence across transactions to avoid circular dependencies.
- Keep transactions brief: The shorter the transaction, the less time locks are held, which helps minimise conflicts.
- Use row-level locks (like
SELECT FOR UPDATE) only when absolutely necessary to avoid unnecessary contention. - Leverage options such as
NOWAITorSKIP LOCKEDto prevent transactions from waiting indefinitely when locks are in conflict.
Applying these practices can help streamline database operations and lower the risk of deadlocks, especially during lengthy transactions.
