PostgreSQL 19 — Monitoring Lock Contention and Fast-Path Overflow with pg_stat_lock
PostgreSQL 19 introduces the pg_stat_lock view, which provides cumulative statistics about lock activity across the database cluster. It helps database administrators investigate lock contention, measure time spent waiting for locks, and identify situations where relation locks exceed the available fast-path slots.
In this article, I will demonstrate these capabilities through two practical scenarios.
postgres=# \d pg_stat_lock
View "pg_catalog.pg_stat_lock"
Column | Type | Collation | Nullable | Default
-------------------+--------------------------+-----------+----------+---------
locktype | text | | |
waits | bigint | | |
wait_time | double precision | | |
fastpath_exceeded | bigint | | |
stats_reset | timestamp with time zone | | |The view contains the following columns:
locktype: Type of lockable object, such as a relation or transaction ID
waits: Number of qualifying lock waits
wait_time: Total time spent waiting for locks, in milliseconds
fastpath_exceeded: Number of times a lock could not be acquired through the fast path because the available slots were exceeded
stats_reset: Time when the statistics were last reset
Initially, all counters are zero in my test environment. The view returns 12 rows, one for each lock type.
postgres=# SELECT * FROM pg_stat_lock;
locktype | waits | wait_time | fastpath_exceeded | stats_reset
------------------+-------+-----------+-------------------+-------------------------------
relation | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
extend | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
frozenid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
page | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
tuple | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
transactionid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
virtualxid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
spectoken | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
object | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
userlock | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
advisory | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
applytransaction | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
(12 rows)An important detail is that waits and wait_time only include waits that exceed deadlock_timeout and end with successful lock acquisition. In my environment, the configured threshold is one second:
vahiddb=# SHOW deadlock_timeout;
deadlock_timeout
------------------
1s
(1 row)Investigating Lock Waits
In this scenario, I will demonstrate how pg_stat_lock records lock waits caused by concurrent transactions.
Concurrent Updates
I will use an existing table named tbl_counter. The table contains a counter row that both sessions attempt to update.
Before starting the experiment, I reset the lock statistics:
vahiddb=# SELECT pg_stat_reset_shared('lock');
pg_stat_reset_shared
----------------------
(1 row)Session 1
Start a transaction and update the counter:
--session 1:
vahiddb=# begin;
update tbl_counter set counter=counter+1;
BEGIN
UPDATE 1The update completes, but the transaction remains open. PostgreSQL retains the relevant locks until the transaction ends.
Session 2
While Session 1 remains open, execute the same update:
--session 2:
vahiddb=# update tbl_counter set counter=counter+1;The second update waits because Session 1 has modified the same row and has not yet committed.
At this point, querying pg_stat_lock shows that the counters have not increased for this wait:
vahiddb=# SELECT * FROM pg_stat_lock;
locktype | waits | wait_time | fastpath_exceeded | stats_reset
------------------+-------+-----------+-------------------+-------------------------------
relation | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
extend | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
frozenid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
page | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
tuple | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
transactionid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
virtualxid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
spectoken | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
object | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
userlock | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
advisory | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
applytransaction | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
(12 rows)The waits and wait_time values remain zero in the displayed statistics.
Why?
The second transaction is still waiting. PostgreSQL records this qualifying wait in the cumulative statistics when the lock is successfully acquired after the wait ends, rather than incrementing the counters continuously while the transaction is blocked.
Session 1
Commit the first transaction:
vahiddb=*# commit;
COMMITSession 2 can now proceed and complete its update.
Query the statistics again:
vahiddb=# SELECT * FROM pg_stat_lock;
locktype | waits | wait_time | fastpath_exceeded | stats_reset
------------------+-------+------------+-------------------+-------------------------------
relation | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
extend | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
frozenid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
page | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
tuple | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
transactionid | 1 | 332098.097 | 0 | 2026-10-26 08:56:30.602553+03
virtualxid | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
spectoken | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
object | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
userlock | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
advisory | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
applytransaction | 0 | 0 | 0 | 2026-10-26 08:56:30.602553+03
(12 rows)The recorded wait_time is approximately 332 seconds.
Table-Level Lock Contention
Now let’s generate a wait involving a relation lock.
First, reset the statistics again:
vahiddb=# SELECT pg_stat_reset_shared('lock');
pg_stat_reset_shared
----------------------
(1 row)Session 1
Acquire an exclusive table lock and keep the transaction open:
--session 1:
vahiddb=# BEGIN;
LOCK TABLE tbl_counter IN ACCESS EXCLUSIVE MODE;
BEGIN
LOCK TABLEThe ACCESS EXCLUSIVE lock conflicts with the ACCESS SHARE lock required by a normal SELECT.
Session 2
While Session 1 holds the lock, execute:
--session 2:
vahiddb=# select * from tbl_counter;The query waits until Session 1 releases its lock.
Session 1
Commit the transaction:
vahiddb=*# commit;
COMMITAfter Session 2 completes, query the statistics:
vahiddb=# SELECT * FROM pg_stat_lock;
locktype | waits | wait_time | fastpath_exceeded | stats_reset
------------------+-------+-----------+-------------------+-------------------------------
relation | 1 | 63338.103 | 0 | 2026-10-26 09:34:16.935286+03
extend | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
frozenid | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
page | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
tuple | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
transactionid | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
virtualxid | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
spectoken | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
object | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
userlock | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
advisory | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
applytransaction | 0 | 0 | 0 | 2026-10-26 09:34:16.935286+03
(12 rows)The relation row records one qualifying wait, with approximately 63.3 seconds of accumulated wait time.
This example differs from the concurrent-update scenario. The ACCESS EXCLUSIVE lock directly prevents another session from acquiring the relation lock required by its SELECT statement. Consequently, the recorded wait appears under relation.
The wait duration depends on the actual time the second session spends waiting. PostgreSQL only includes waits exceeding deadlock_timeout that subsequently end in successful acquisition.
Detecting Fast-Path Lock Overflow
PostgreSQL uses a fast-path mechanism to acquire certain relation locks efficiently. When the available fast-path slots are insufficient for a backend’s relation-lock requirements, additional lock acquisitions can use the regular shared lock table instead.
The fastpath_exceeded counter helps identify this activity. Unlike waits and wait_time, it does not depend on a lock wait exceeding deadlock_timeout.
This is particularly interesting for partition-heavy workloads, where a single query can access thousands of relations.
First, check the current value of max_locks_per_transaction:
vahiddb=# SHOW max_locks_per_transaction;
max_locks_per_transaction
---------------------------
128
(1 row)To generate a workload that accesses many relations, I created a range-partitioned table with 5,000 partitions.
vahiddb=# CREATE TABLE tbl_parts (
id integer
) PARTITION BY RANGE (id);
CREATE TABLENext, I created the partitions using a DO block:
vahiddb=# DO $$
BEGIN
FOR i IN 1..5000 LOOP
EXECUTE format(
'CREATE TABLE tbl_parts_%s
PARTITION OF tbl_parts
FOR VALUES FROM (%s) TO (%s)',
i,
(i - 1) * 1000,
i * 1000
);
END LOOP;
END $$;
DO
Time: 68588.738 ms (01:08.589)The operation completed successfully and took approximately 68.6 seconds in my environment.
The table is empty, but its partition structure provides a useful test case because a query against the parent can require locks on many relations.
Before executing the test query, I reset the lock statistics:
vahiddb=# SELECT pg_stat_reset_shared('lock');
pg_stat_reset_shared
---------------------
(1 row)At this stage, the fastpath_exceeded counters were zero.
Resetting the statistics makes it easier to attribute subsequent counter changes to the experiment. This function resets cluster-wide lock statistics, so it should be used carefully on shared or production servers.
Now, execute a simple aggregate query:
vahiddb=# SELECT count(*) FROM tbl_parts;
count
-------
0
(1 row)Although the table contains no rows, PostgreSQL still needs to process the partitioned relation and its partitions. The query therefore provides a way to investigate relation-lock activity without inserting thousands of rows.
Next, query pg_stat_lock again:
vahiddb=# SELECT * FROM pg_stat_lock;
locktype | waits | wait_time | fastpath_exceeded | stats_reset
------------------+-------+-----------+-------------------+-------------------------------
relation | 0 | 0 | 92796 | 2026-10-26 10:13:47.582012+03
extend | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
frozenid | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
page | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
tuple | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
transactionid | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
virtualxid | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
spectoken | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
object | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
userlock | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
advisory | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
applytransaction | 0 | 0 | 0 | 2026-10-26 10:13:47.582012+03
(12 rows)The fastpath_exceeded counter increased to 92,796.
This result shows that the workload generated many relation-lock acquisitions that could not use the fast path because its available slots had been exceeded.
Conclusion
The pg_stat_lock view in PostgreSQL 19 provides a useful way to investigate lock activity through cumulative statistics.
Comments
Post a Comment