I set up a row-locking contention test against PostgreSQL 19rc1 to see what its new pg_stat_lock view actually captures, and the first four runs reported nothing at all. Not zero waits with a caveat. Zero rows changed, full stop, on a view that is supposed to be a cumulative counter of lock waits.
pg_stat_lock is new in PostgreSQL 19, still a release candidate as of this week. It's a cluster-wide, cumulative view with one row per lock type, meant to answer a question that used to require either polling pg_locks and hoping you caught the contention in the act, or turning on log_lock_waits and grepping log files. I pulled postgres:19rc1 from Docker Hub, ran it, and tried to break the view.
What the view looks like
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 | | | Twelve rows out of the box, one for each lock type PostgreSQL tracks internally: relation, extend, frozenid, page, tuple, transactionid, virtualxid, spectoken, object, userlock, advisory, applytransaction. All zero on a fresh cluster.
The first result was nothing, and that was the real finding
My first test was simple: one session takes SELECT * FROM accounts WHERE id=1 FOR UPDATE, holds it for 200ms, a second session tries the same row, and I check pg_stat_lock afterwards. I measured the second session's wait at 186ms with a stopwatch in the test harness. pg_stat_lock showed twelve rows of zero.
My first assumption was that PostgreSQL's statistics collector batches writes and I just hadn't waited long enough. I checked again after a 2-second delay. Still zero. So I ran the same test with a 3-second hold instead of 200ms, and it worked immediately:
locktype | waits | wait_time | fastpath_exceeded | stats_reset --------------+-------+-----------+-------------------+------------------------------- transactionid | 1 | 2991.719 | 0 | 2026-11-05 05:09:52+00 That ruled out a stats-flush delay and pointed at a duration threshold instead. I binary-searched it by holding the lock for increasing durations and checking whether pg_stat_lock recorded anything:
| Actual wait | Recorded? |
|---|---|
| 490ms | no |
| 936ms | no |
| 986ms | no |
| 999ms | no |
| 1037ms | yes, wait_time 1035.1 |
| 1089ms | yes, wait_time 1089.7 |
The cutoff sits right at one second, which is PostgreSQL's default deadlock_timeout. I confirmed it isn't a coincidence by lowering deadlock_timeout to 200ms and rerunning the same sweep:
| Actual wait | Recorded? |
|---|---|
| 134ms | no |
| 189ms | no |
| 199ms | no |
| 235ms | yes, wait_time 235.6 |
| 288ms | yes, wait_time 288.0 |
The threshold moved with the setting. pg_stat_lock isn't sampling or batching short waits at a lower weight, it is simply not instrumented at the point where a lock wait begins. It's instrumented at the point where PostgreSQL would otherwise run a deadlock check, which only happens after a backend has been waiting for deadlock_timeout. Anything that resolves before that point leaves no trace anywhere in this view.
It isn't a new gap, it's the same old gap
Before pg_stat_lock, the way to see lock waits in the logs was log_lock_waits, which logs a line when a backend has been waiting past deadlock_timeout. I turned it on and ran a wait at 150ms (below my 200ms deadlock_timeout) and a wait at 400ms (above it):
2026-11-05 05:12:18.739 UTC LOG: process 251 still waiting for ShareLock on transaction 718 after 200.214 ms 2026-11-05 05:12:18.775 UTC LOG: process 251 acquired ShareLock on transaction 718 after 235.648 ms Only the 400ms wait produced any log line, and pg_stat_lock recorded 235.648ms as wait_time, an exact match to the log entry. The 150ms wait left nothing in either place. So pg_stat_lock isn't worse than what came before it. It's built on the same signal log_lock_waits has always used, just without needing to parse text files. If you were relying on log_lock_waits to catch contention, you already had this blind spot and didn't know it had a name.
What ten queued sessions actually wait on
I wanted to know whether queued waiters pile onto one counter or split across lock types, so I ran one holder and then 1, 10, 25 and 50 sessions all requesting the same row FOR UPDATE, with the hold long enough (1.2 seconds) to clear the threshold.
With 10 waiters, pg_stat_lock showed this:
| locktype | waits | wait_time (ms) |
|---|---|---|
| transactionid | 1 | 1199.2 |
| tuple | 9 | 10829.3 |
Only the first waiter in the queue counts against transactionid; it is waiting directly on the holding transaction's ID to finish. Every other queued session counts against tuple instead, because PostgreSQL has them queue on a short-lived tuple lock behind the first waiter rather than all piling onto the same transaction ID lock. At 50 waiters the pattern held exactly: 1 transactionid wait and 49 tuple waits, averaging about 1.23 seconds each as the queue drained. Nothing degraded or stalled; the 50th waiter wasn't dramatically worse off than the 10th. But the split between transactionid and tuple isn't something I'd have guessed from the view's own description as "lock waits by type," and it means a dashboard built on this view needs to know to add both rows together to get "how much contention is actually happening on rows," not just look at the type a reader would naturally assume, tuple.
It is not a live view
I checked whether pg_stat_lock updates while a wait is still happening, since that would make it usable as a "who is blocked right now" signal in a monitoring query. I queried it 1.6 seconds into a 3-second wait:
mid-wait (1.6s into a 3s wait): all twelve rows at waits=0 after resolution: transactionid waits=1, wait_time=3000.263 Nothing shows up until the wait ends. This confirms pg_stat_lock is a history of waits that have already finished, not a gauge of what's blocked right now. For "what is blocked at this instant," you still need pg_locks or pg_stat_activity.wait_event. The two views answer different questions and neither replaces the other: pg_locks tells you what's stuck now, pg_stat_lock tells you how often and for how long over the last hour, day, or since the last reset.
What it refuses to let you do
Reading the view needs no special privilege. I created a plain role with no grants at all and it could SELECT * FROM pg_stat_lock without issue, same as any other pg_stat_* view. Resetting it is a different story:
ERROR: permission denied for function pg_stat_reset_shared I tried this as a role granted pg_monitor, which normally gets you read access to almost everything observability-related in PostgreSQL. It still got denied. Only superuser can reset pg_stat_lock's counters. If your monitoring account runs as a pg_monitor-scoped role, which is the recommended practice, it can read this view all day but can't clear it, and will need a separate superuser path if you ever want pg_stat_reset_shared('lock') scripted.
I also tried a bad target name to see what PostgreSQL would say:
ERROR: unrecognized reset target: "locks" HINT: Target must be "archiver", "bgwriter", "checkpointer", "io", "lock", "recovery_prefetch", "slru", or "wal". lock (singular) is confirmed as a real target in that list, which is a small but easy mistake to make from memory since the view itself is named pg_stat_lock and the function wants 'lock', not 'locks' or 'lock_waits'.
One thing I could not trigger
The view has a fastpath_exceeded column, meant to count when a backend's fast-path lock slots (used for cheap relation-level locks) overflow into the slower shared lock table. I opened a transaction, selected from 40 separate tables inside it, and fastpath_exceeded stayed at zero across every locktype. I don't know whether the actual threshold is just higher than 40 distinct relations in PostgreSQL 19, or whether triggering it needs a different access pattern than plain SELECTs. I'm reporting this as unverified rather than claiming either explanation.
What I got wrong on the way
My first attempt at the 10/25/50-waiter concurrency test hung forever. I'd built it around a threading.Barrier sized for "holder plus N waiters," but only the holder thread and my own main thread ever called .wait() on it, the waiter threads didn't. With one waiter this accidentally worked, because the barrier only needed two arrivals and got them by coincidence. With five or more waiters it needed more arrivals than would ever come, and the whole test sat there for minutes doing nothing while I assumed PostgreSQL was just slow. pg_stat_activity showed the real story immediately: one session sitting idle in transaction on a completed lock grab, and nothing else happening at all. The fix was to have every waiter thread call barrier.wait() and to stop having the main thread call it too, since that was eating one of the arrival slots before any waiter thread had even started.
Run it yourself
docker run -d --name pgtest -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:19rc1 docker exec pgtest psql -U postgres -c "CREATE TABLE accounts (id int primary key, balance int);" docker exec pgtest psql -U postgres -c "INSERT INTO accounts VALUES (1, 100);" docker exec pgtest psql -U postgres -c "SELECT pg_stat_reset_shared('lock');" This is the harness, trimmed to the single-waiter case that found the threshold in the first place:
import psycopg2, threading, time DSN = "host=127.0.0.1 port=55432 user=postgres password=test dbname=postgres" def holder(hold_ms, barrier): c = psycopg2.connect(DSN); c.autocommit = False cur = c.cursor() cur.execute("BEGIN;") cur.execute("SELECT * FROM accounts WHERE id=1 FOR UPDATE;") barrier.wait(timeout=10) time.sleep(hold_ms / 1000.0) cur.execute("COMMIT;") c.close() def waiter(results, idx, barrier): c = psycopg2.connect(DSN); c.autocommit = False cur = c.cursor() barrier.wait(timeout=10) t0 = time.perf_counter() cur.execute("BEGIN;") cur.execute("SELECT * FROM accounts WHERE id=1 FOR UPDATE;") results[idx] = (time.perf_counter() - t0) * 1000.0 cur.execute("COMMIT;") c.close() barrier = threading.Barrier(2) results = [None] th = threading.Thread(target=holder, args=(1050, barrier)); th.start() w = threading.Thread(target=waiter, args=(results, 0, barrier)); w.start() th.join(); w.join() print("waiter waited", round(results[0], 1), "ms") Then check SELECT * FROM pg_stat_lock WHERE waits > 0; and compare against the number this script printed.
What to do with this
If you plan to alert on pg_stat_lock, know what it won't tell you. It will not catch the common case of row contention that resolves in a few hundred milliseconds under normal application load, which is most lock contention most applications see. It is built for the slow, deadlock-check-adjacent tail, not the everyday jitter. If you need visibility into sub-second waits, you still need pg_locks sampled frequently, or you need to lower deadlock_timeout, which changes how aggressively PostgreSQL runs deadlock detection cluster-wide and is not a change to make only for observability reasons without thinking through the cost. And if you're building a dashboard that reports "lock contention by type," remember that one blocked row under contention from several sessions shows up as one transactionid wait and several tuple waits, not several waits of the same type.