The reader that locks the table
A SELECT under READ COMMITTED, with no hint and no open transaction. Even so, it ends up holding an S lock on the whole table, and every INSERT has to wait. We showed this on LinkedIn in 2022. Now we have measured it again on SQL Server 2019, 2022 and 2025, and looked over the query’s shoulder in the middle of its execution.
The case in 30 seconds
- Parameter, first call
@p1 = 0.8A plan for a few rows. The first call with0.8produces two nested loops with prefetch and a key lookup. - Parameter, second call
@p1 = 800Executed with all rows. With800, the same plan reads all 10,000 rows of TestTab2. For every row fromix_ID_Tab1, an S lock stays in place. - TestTab2 rows read6,222Escalation at 6,222. Up to 6,220 held locks, it stays with row locks. Two rows later, there is an S lock on the whole table. The extended event reports 6,248 escalated locks, cause “Lock threshold”.
- Writers’ wait type
LCK_M_IXThen every writer waits. INSERT, UPDATE and DELETE on TestTab2 wait withLCK_M_IXuntil the SELECT is done. That includes rows the SELECT never touches.
It is the plan that holds the locks, not the isolation level.
That is the thesis of this article. When a key lookup sits behind a nested loop with prefetch, SQL Server keeps the locks on the index rows it has read until the end of the statement. The section “Why does a reader hold locks?” explains why.
The setup
As in 2022, we use the tables from Fabiano Amorim’s article on prefetching from 2012:
- TestTab1 with 30,000 rows and an index on
Col4. - TestTab2 with 10,000 rows and an index on
ID_Tab1. - Wide
charcolumns make sure that almost every row sits on a page of its own.
We generate the data deterministically, so that all three versions see exactly the same rows.
The query lives in a procedure:
dbo.p2CREATE OR ALTER PROCEDURE dbo.p2 @p1 decimal(18,2)
AS
SELECT TestTab1.Col4, TestTab2.Col1
FROM dbo.TestTab1
INNER JOIN dbo.TestTab2 ON TestTab1.ID = TestTab2.ID_Tab1
WHERE TestTab1.Col4 < @p1;EXEC dbo.p2 0.8; -- puts the plan into the cache: 37 rows
EXEC dbo.p2 800; -- keeps using it: 10,000 rowsThe first call determines the plan, the second one inherits it: classic parameter sniffing. For the cached plan, the optimizer estimates 273 result rows. In the second call, it is 10,000.
EXEC dbo.p2 0.8, measured with SQL Server 2019
Choose the version. Each box shows the rows estimated when compiling for 0.8 → actual when calling with 800.
- Compiled for
- –
- Estimated rows
- –
- Actual at 800
- –
- Escalated locks (XE)
- –
An X-ray of a running query
How do you see which locks a query holds in the middle of its execution? We stop it.
A second session changes the Col4 value of a specific TestTab1 row in an open transaction. When the SELECT reaches that row, it has to wait. At exactly that moment, we read sys.dm_tran_locks for the SELECT’s session. The choice of the locked row determines how far the SELECT gets before. That gives us an X-ray, frame by frame.
1 dot = 50 S key locks on ix_ID_Tab1 · the grid holds all 10,000 rows of TestTab2
- TestTab2 rows read
- –
- S key locks held
- –
- Total locks
- –
- TestTab2
- –
index_lock_promotion_attempt_count- –
index_lock_promotion_count- –
What the X-ray shows
- One lock per row, on one index only. Before the escalation, the SELECT holds exactly as many S key locks as the index seek on
ix_ID_Tab1has returned rows. It holds no row locks on the clustered index of TestTab2, and only intent locks on TestTab1. - Without prefetch, not a single one, in 2019 and 2022. With the same plan without prefetch (TF 8744), the SELECT held no key lock at any freeze point. This is what READ COMMITTED normally looks like. In 2025, the additional batch sort holds the locks even without prefetch; only without both do they go away there.
- The threshold is not 5,000. Microsoft names 5,000 locks as the trigger. But the check only happens at checkpoints 1,250 locks apart, and escalation only happens once a checkpoint finds at least 5,000 locks on one index. In our case, that is the checkpoint at 6,250 locks in total. With 6,220 key locks plus intent locks, the SELECT is just below it; two rows later, it escalates. Paul White worked out this behavior in detail in 2022. On SQL Server 2019 and 2022, our measurement matches it to the row. On 2025, the extended event reports the same count; because of the batch sort, freezing can only narrow it down block by block there.
- The
attemptcounter already rises at checkpoints where nothing can escalate. In our runs,index_lock_promotion_attempt_countrose when 2,500, 3,750 and 5,000 locks were passed, by 4 each time in 2019 and 2022.index_lock_promotion_countrises exactly once.
Why does a reader hold locks?
Under locking READ COMMITTED, SQL Server normally releases the lock on a row as soon as the row has been read.
With prefetch, SQL Server reads ahead. It collects the pointers from the index seek, issues the reads for the lookups in batches and processes the rows as soon as they are in memory. This creates a gap between seek and lookup. If the lock on the seek row were released right away, another session could change or delete the row in that gap. The lookup would then find something other than what the seek found. That is why SQL Server holds these locks until the end of the statement.
ix_ID_Tab1collect pointersPrefetchreads aheadgapKey LookupPK_TestTab2This is not a bug but by design:
- Craig Freedman described it in 2007 for lookups behind prefetch, batch sort and sort.
- Paul White calls such locks “lock classes”.
- Microsoft documents that part of the query is raised to repeatable read for this.
SQL Server 2022 and 2025 even say so themselves. In our case, their extended event lock_escalation reports lock_escalation_with_lock_class = true.
Two refinements from our measurement:
- Microsoft writes that the locks sit on the clustered index and on a nonclustered index. In our case, they sat only on the nonclustered index of the seek.
- Prefetch without a lookup held no locks in our tests. With a covering index, the problem was gone, even though the nested loop kept its prefetch.
So what matters is the plan, not the isolation level: a lookup behind an operator that reads ahead.
What the escalated reader lock does
Time axis schematic. Wait times are lab values, not production values: they depend on how long we artificially hold the reader. Three runs per case.
- Before the escalation, thousands of S key locks sit on
ix_ID_Tab1. INSERT and an UPDATE ofCol1still run through, because they do not touch that index. Only a DELETE of a row already read waits withLCK_M_X, because it has to remove the locked index entry. - After the escalation, every writer on TestTab2 waits with
LCK_M_IX, even the INSERT of a brand-new row. The chain is blocker → reader → writer. The writer finishes 0 to 4 ms after the reader: it waits practically as long as the SELECT runs. - Without prefetch, nobody waits in 2019 and 2022.
In the lab, the SELECT only takes seconds because we hold it artificially. In a large OLTP environment, a report that waits a few seconds on I/O can be enough. If it escalates, all writers to that table queue up behind it, and the application behind them. That is exactly the picture we had seen in 2022 in several large environments.
Parameter sniffing as a switch
Whether a plan gets prefetch is decided by the optimizer at compile time, based on the estimated rows on the outer side of the nested loops. In all three versions, it tips at 25:
- At 24.92 estimated rows, there is no prefetch.
- At 25.14 rows, there is prefetch.
Source: the lab’s sniffing matrix per version, tipping points bisected to 0.01. Logarithmic scale from 0.02 to 214.74, the smallest and largest Col4 value.
So the dangerous values are not the extreme ones. In our setup, a first call with slightly more than 25 estimated rows was enough, and the plan stays in the cache until something evicts it.
One detail for assessing countermeasures: the freshly compiled plan for 800 (OPTION (RECOMPILE)) does not escalate. But it runs in parallel with DOP 8 and took 1.7 to 2.8 seconds in the lab. Depending on the version, the sniffed plan that locks the table was clearly faster, at 0.36 to 0.75 seconds (median). So here, the fast plan is precisely the one that locks.
Countermeasures tested
| Variant | 2019 | 2022 | 2025 | Assessment |
|---|---|---|---|---|
| Baseline | escalates | escalates | escalates | |
| RCSI | none | none | none | Readers take no S locks. Plan for the version store in tempdb, and review code that relies on blocking reads. |
| SNAPSHOT (session) | none | none | none | Readers take no S locks, as with RCSI. Requires ALLOW_SNAPSHOT_ISOLATION, the session opts in; update conflicts are possible. |
Covering index (INCLUDE (Col1)) | none | none | none | no lookup, no held locks. More space and more write overhead. |
OPTION (RECOMPILE) | none | none | none | Compiled for the current value. Compilation cost, and here a slower parallel plan. |
QUERYTRACEON 8744 | none | none | escalates | in 2025, the batch sort holds the locks. Documented only for global/session scope. |
8744 + DISABLE_OPTIMIZED_NESTED_LOOP | none | none | none | only both together help in 2025 |
LOCK_ESCALATION = DISABLE | none | none | none | but 8,001 (2025: 7,555) individual locks held at the same point. The symptom just moves. |
NOLOCK | none | none | none | not recommended: dirty reads, missing or duplicate rows |
| Optimized locking, RCSI off | – | – | escalates | acts on writers, not on these reader locks |
| Optimized locking + RCSI | – | – | none | the effect comes from RCSI |
Our order in practice:
- Get rid of the lookup if the query matters. A covering index or a narrower column list.
- Consider RCSI if the application can handle it. Many can, some cannot.
- Prevent a plan built for a few rows from running with very many rows, depending on the pattern with RECOMPILE, a plan guide or a plan forced in Query Store.
Trace flags, LOCK_ESCALATION = DISABLE and NOLOCK are not standard fixes.
And SQL Server 2025 with optimized locking?
The obvious assumption: SQL Server 2025 with optimized locking solves this. Measured: no, at least not without RCSI.
Optimized locking changes how writers lock. They lock their transaction instead of every single row, and they check rows before they lock them. But our locks are held by the reader. With optimized locking and without RCSI, the SELECT escalates just the same, and the INSERT waits just the same. Only with RCSI does the problem go away, and then that is due to RCSI. Microsoft describes optimized locking only for INSERT, UPDATE, DELETE and MERGE (documentation).
SQL Server 2025 sometimes heals itself
With Query Store active, SQL Server 2025 replaced the sniffed plan on its own after 16 escalating calls in our run. The reason was “Cardinality estimation feedback” (CE_FEEDBACK_FOR_EXPRESSIONS). After that, a parallel hash plan ran without escalation. SQL Server 2022 also tried a verification plan once and then returned to the old plan. Without Query Store, the plan stayed stable.
We would not rely on it. The timing depends on the workload, and until then every call locks.
Three versions, one pattern
| 2019 CU32 | 2022 CU13 | 2025 CU7 | |
|---|---|---|---|
Plan after 0.8 | 2 × NL with Prefetch + Key Lookup | same | same, plus batch sort |
Escalation at 800 | yes, 6,248 locks | yes, 6,248 | yes, 6,247 |
| Last state without escalation | 6,220 key locks | 6,220 | 5,109 (batch sort collects in blocks) |
| XE reports “lock class” | field missing | true | true |
| TF 8744 helps | yes | yes | no, only with DISABLE_OPTIMIZED_NESTED_LOOP |
| Optimized locking | – | – | helps only with RCSI |
| Plan stays stable | yes | one verification run | replaced after 16 calls (CE feedback) |
How to find this on your systems
We checked all four queries against the case in the lab. Choose a question. The copy button always gives you the complete file, including its comment header.
Which indexes escalate?
What it shows: Per index, the counters index_lock_promotion_attempt_count (rises at checkpoints) and index_lock_promotion_count (escalations that took place) from sys.dm_db_index_operational_stats.
When to use it: As a first look at a server. The values accumulate since the metadata was loaded; only differences between two measurements are meaningful.
Notes from the comment header
- index_lock_promotion_attempt_count rises at checkpoints (in the lab also without a subsequent escalation), index_lock_promotion_count counts the escalations that took place.
- The values accumulate since the metadata was loaded (restart, database offline, eviction from the metadata cache). Only differences between two measurements are meaningful.
/*
Diagnostic (a): lock escalation counters per index in the current database.
- index_lock_promotion_attempt_count rises at checkpoints (in the lab also without a subsequent
escalation), index_lock_promotion_count counts the escalations that took place.
- The values accumulate since the metadata was loaded (restart, database offline, eviction from the
metadata cache). Only differences between two measurements are meaningful.
Tested in the lock escalation prefetch lab (SQL Server 2019/2022/2025).
*/
SELECT OBJECT_SCHEMA_NAME(s.object_id) AS schema_name,
OBJECT_NAME(s.object_id) AS table_name,
s.index_id,
i.name AS index_name,
s.partition_number,
s.index_lock_promotion_attempt_count,
s.index_lock_promotion_count,
s.row_lock_count,
s.row_lock_wait_count,
s.row_lock_wait_in_ms,
s.page_lock_count,
s.page_lock_wait_count,
s.page_lock_wait_in_ms
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS s
JOIN sys.indexes AS i
ON i.object_id = s.object_id
AND i.index_id = s.index_id
WHERE OBJECTPROPERTY(s.object_id, 'IsUserTable') = 1
AND s.index_lock_promotion_attempt_count > 0
ORDER BY s.index_lock_promotion_count DESC, s.index_lock_promotion_attempt_count DESC;
| table_name | index_name | Δ index_lock_promotion_attempt_count | Δ index_lock_promotion_count |
|---|---|---|---|
| TestTab2 | ix_ID_Tab1 | 13 | 1 |
| TestTab2 | PK_TestTab2 | 13 | 0 |
| TestTab1 | ix_Col4 | 7 | 0 |
| table_name | index_name | Δ index_lock_promotion_attempt_count | Δ index_lock_promotion_count |
|---|---|---|---|
| TestTab2 | ix_ID_Tab1 | 13 | 1 |
| TestTab2 | PK_TestTab2 | 13 | 0 |
| TestTab1 | ix_Col4 | 7 | 0 |
| table_name | index_name | Δ index_lock_promotion_attempt_count | Δ index_lock_promotion_count |
|---|---|---|---|
| TestTab2 | ix_ID_Tab1 | 7 | 1 |
| TestTab2 | PK_TestTab2 | 7 | 0 |
| TestTab1 | ix_Col4 | 4 | 0 |
Which plans have prefetch ahead of a lookup?
What it shows: Every nested loops with WithUnorderedPrefetch or WithOrderedPrefetch in the plan cache, the lookup on its inner side and the estimate the plan was compiled for.
When to use it: When the counters rise and you are looking for the plan behind them. Expensive on large systems, so narrow it down first.
Notes from the comment header
- Returns, per cached statement, every nested loops operator with WithUnorderedPrefetch or WithOrderedPrefetch and shows whether its inner side contains a key/RID lookup (inner_lookup_table/inner_lookup_index). Prefetch ahead of a lookup is the pattern in which a SELECT under READ COMMITTED holds the S locks of the outer rows until the end of the statement.
- outer_estimate_rows shows the row count the plan was compiled for.
- The XQuery over the whole plan cache is expensive. On large production systems, narrow it down first, e.g. to one database (qp.dbid) or to plans with a high execution count.
/*
Diagnostic (b): plan cache search for nested loops with prefetch.
- Returns, per cached statement, every nested loops operator with WithUnorderedPrefetch or
WithOrderedPrefetch and shows whether its inner side contains a key/RID lookup
(inner_lookup_table/inner_lookup_index). Prefetch ahead of a lookup is the pattern in which a SELECT
under READ COMMITTED holds the S locks of the outer rows until the end of the statement.
- outer_estimate_rows shows the row count the plan was compiled for.
- The XQuery over the whole plan cache is expensive. On large production systems, narrow it down first,
e.g. to one database (qp.dbid) or to plans with a high execution count.
Tested in the lock escalation prefetch lab (SQL Server 2019/2022/2025).
*/
WITH XMLNAMESPACES (DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan')
SELECT DB_NAME(qp.dbid) AS database_name,
OBJECT_SCHEMA_NAME(qp.objectid, qp.dbid) + N'.'
+ OBJECT_NAME(qp.objectid, qp.dbid) AS object_name,
cp.objtype,
cp.usecounts,
LEFT(stmt.n.value('@StatementText', 'nvarchar(max)'), 300) AS statement_text,
nl.n.value('@NodeId', 'int') AS nested_loops_node_id,
CASE WHEN nl.n.exist('NestedLoops[@WithUnorderedPrefetch = ("1", "true")]') = 1
THEN N'unordered' ELSE N'ordered' END AS prefetch,
nl.n.value('(NestedLoops/RelOp/@EstimateRows)[1]', 'float') AS outer_estimate_rows,
lk.n.value('(IndexScan/Object/@Table)[1]', 'sysname') AS inner_lookup_table,
lk.n.value('(IndexScan/Object/@Index)[1]', 'sysname') AS inner_lookup_index,
cp.plan_handle
FROM sys.dm_exec_cached_plans AS cp
CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) AS qp
CROSS APPLY qp.query_plan.nodes('//StmtSimple') AS stmt(n)
CROSS APPLY stmt.n.nodes('.//RelOp[NestedLoops[@WithUnorderedPrefetch = ("1", "true")
or @WithOrderedPrefetch = ("1", "true")]]') AS nl(n)
OUTER APPLY nl.n.nodes('(NestedLoops/RelOp[2]/descendant-or-self::RelOp[IndexScan/@Lookup = ("1", "true")])[1]') AS lk(n)
WHERE cp.cacheobjtype = N'Compiled Plan'
AND qp.query_plan.exist('//NestedLoops[@WithUnorderedPrefetch = ("1", "true")
or @WithOrderedPrefetch = ("1", "true")]') = 1
ORDER BY cp.usecounts DESC, database_name, object_name, nested_loops_node_id;
| nested_loops_node_id | prefetch | outer_estimate_rows | inner_lookup_table | inner_lookup_index |
|---|---|---|---|---|
| 0 | unordered | 273.045 | [TestTab2] | [PK_TestTab2] |
| 2 | unordered | 117.846 | NULL | NULL |
| nested_loops_node_id | prefetch | outer_estimate_rows | inner_lookup_table | inner_lookup_index |
|---|---|---|---|---|
| 0 | unordered | 273.045 | [TestTab2] | [PK_TestTab2] |
| 2 | unordered | 117.846 | NULL | NULL |
| nested_loops_node_id | prefetch | outer_estimate_rows | inner_lookup_table | inner_lookup_index |
|---|---|---|---|---|
| 0 | unordered | 273.045 | [TestTab2] | [PK_TestTab2] |
| 3 | unordered | 117.846 | NULL | NULL |
Who escalates when?
What it shows: A session on sqlserver.lock_escalation with statement, session and host, plus the query that reads the events from the ring_buffer.
When to use it: When you want to find the escalating statement and watch it for hours. Remove the session afterwards.
Caution: do not start this on a production system without thinking it through
An Extended Events session applies to the whole server and costs work for every captured event. lock_escalation is usually a rare event. On a system with many escalations, however, the actions of this session (SQL text, plan handle) add load.
- Try it on a test system first. On production, only after agreeing it with operations and through your usual change process.
- Restrict it to the affected database with a predicate, limit its runtime and watch load and event rate.
- Stop and drop it afterwards; the clean-up block is at the end of the file. The session is created with
STARTUP_STATE = OFFand does not start on its own after a restart. - Do not add events such as
lock_acquiredorlock_released. They fire for every single lock and can slow a server down considerably.
Notes from the comment header
- Event sqlserver.lock_escalation with statement and database name, plus session, SQL text, host name, application and plan handle. From SQL Server 2022 on, the event also contains the field lock_escalation_with_lock_class (not present in 2019 in the lab).
- ring_buffer target: in memory only, lost when the session stops. For continuous use, use an event_file target instead.
- Optionally restrict to one database with a predicate, e.g. ADD EVENT sqlserver.lock_escalation (... WHERE (sqlserver.database_name = N'MyDatabase'))
- mode S or X on resource_type OBJECT is the escalated table lock.
- escalated_lock_count: number of finer-grained locks replaced; hobt_lock_count: locks on the escalating HoBt at the time of the escalation.
- Events appear only after the session's dispatch latency (here up to 5 seconds).
- Timestamps are UTC.
/*
Diagnostic (c), part 1: Extended Events session for lock escalations.
! CAUTION: Do not start this on a production system without thinking it through.
! Try it on a test system first; on production only after agreeing it with operations.
! Restrict it to one database with a predicate, limit the runtime, watch the load.
! Stop and drop it afterwards (clean-up block at the end). Do not add events such as lock_acquired.
- Event sqlserver.lock_escalation with statement and database name, plus session, SQL text,
host name, application and plan handle. From SQL Server 2022 on, the event also contains the field
lock_escalation_with_lock_class (not present in 2019 in the lab).
- ring_buffer target: in memory only, lost when the session stops. For continuous use, use an
event_file target instead.
- Optionally restrict to one database with a predicate, e.g.
ADD EVENT sqlserver.lock_escalation (... WHERE (sqlserver.database_name = N'MyDatabase'))
Tested in the lock escalation prefetch lab (SQL Server 2019/2022/2025).
*/
IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'psg_lock_escalation')
DROP EVENT SESSION psg_lock_escalation ON SERVER;
CREATE EVENT SESSION psg_lock_escalation ON SERVER
ADD EVENT sqlserver.lock_escalation (
SET collect_database_name = (1), collect_statement = (1)
ACTION (sqlserver.session_id, sqlserver.sql_text, sqlserver.client_hostname,
sqlserver.client_app_name, sqlserver.plan_handle)
)
ADD TARGET package0.ring_buffer (SET max_events_limit = (1000))
WITH (MAX_DISPATCH_LATENCY = 5 SECONDS, STARTUP_STATE = OFF);
ALTER EVENT SESSION psg_lock_escalation ON SERVER STATE = START;
/* Clean up when the session is no longer needed:
ALTER EVENT SESSION psg_lock_escalation ON SERVER STATE = STOP;
DROP EVENT SESSION psg_lock_escalation ON SERVER;
*/
/*
Diagnostic (c), part 2: read lock escalation events from the ring_buffer of the session
psg_lock_escalation (see c-xe-lock-escalation-session.sql).
- mode S or X on resource_type OBJECT is the escalated table lock.
- escalated_lock_count: number of finer-grained locks replaced; hobt_lock_count: locks on the
escalating HoBt at the time of the escalation.
- Events appear only after the session's dispatch latency (here up to 5 seconds).
- Timestamps are UTC.
Tested in the lock escalation prefetch lab (SQL Server 2019/2022/2025).
*/
WITH rb AS (
SELECT CAST(t.target_data AS xml) AS x
FROM sys.dm_xe_sessions AS s
JOIN sys.dm_xe_session_targets AS t
ON t.event_session_address = s.address
WHERE s.name = N'psg_lock_escalation'
AND t.target_name = N'ring_buffer'
)
SELECT e.n.value('@timestamp', 'datetime2(3)') AS utc_time,
e.n.value('(data[@name = "database_name"]/value)[1]', 'sysname') AS database_name,
OBJECT_NAME(e.n.value('(data[@name = "object_id"]/value)[1]', 'int'),
e.n.value('(data[@name = "database_id"]/value)[1]', 'int')) AS object_name,
e.n.value('(data[@name = "hobt_id"]/value)[1]', 'bigint') AS hobt_id,
e.n.value('(data[@name = "resource_type"]/text)[1]', 'nvarchar(60)') AS resource_type,
e.n.value('(data[@name = "mode"]/text)[1]', 'nvarchar(60)') AS mode,
e.n.value('(data[@name = "escalation_cause"]/text)[1]', 'nvarchar(60)') AS escalation_cause,
e.n.value('(data[@name = "escalated_lock_count"]/value)[1]', 'int') AS escalated_lock_count,
e.n.value('(data[@name = "hobt_lock_count"]/value)[1]', 'int') AS hobt_lock_count,
e.n.value('(data[@name = "lock_escalation_with_lock_class"]/value)[1]',
'nvarchar(10)') AS with_lock_class, -- from 2022 on
e.n.value('(action[@name = "session_id"]/value)[1]', 'int') AS session_id,
e.n.value('(action[@name = "client_hostname"]/value)[1]', 'nvarchar(128)') AS client_hostname,
e.n.value('(data[@name = "statement"]/value)[1]', 'nvarchar(max)') AS statement,
e.n.value('(action[@name = "sql_text"]/value)[1]', 'nvarchar(max)') AS sql_text
FROM rb
CROSS APPLY rb.x.nodes('/RingBufferTarget/event[@name = "lock_escalation"]') AS e(n)
ORDER BY utc_time;
| object_name | resource_type | mode | escalation_cause | escalated_lock_count | hobt_lock_count | with_lock_class | client_hostname |
|---|---|---|---|---|---|---|---|
| TestTab2 | OBJECT | S | Lock threshold | 6248 | 6245 | NULL | LESC-RDR |
| object_name | resource_type | mode | escalation_cause | escalated_lock_count | hobt_lock_count | with_lock_class | client_hostname |
|---|---|---|---|---|---|---|---|
| TestTab2 | OBJECT | S | Lock threshold | 6248 | 6245 | true | LESC-RDR |
| object_name | resource_type | mode | escalation_cause | escalated_lock_count | hobt_lock_count | with_lock_class | client_hostname |
|---|---|---|---|---|---|---|---|
| TestTab2 | OBJECT | S | Lock threshold | 6247 | 6245 | true | LESC-RDR |
What is a session holding right now?
What it shows: Held and waiting locks per session, object, index and mode, aggregated, with wait type and blocking session.
When to use it: In the middle of blocking. Many KEY locks with mode S for a reader, or OBJECT with S instead of IS, show the pattern from this article.
Notes from the comment header
- Many KEY locks with mode S for a reading session under READ COMMITTED are unusual: a hint at locks held until the end of the statement, e.g. nested loops with prefetch ahead of a lookup.
- OBJECT with mode S (instead of IS) means a table lock, for a reader typically after a lock escalation.
/*
Diagnostic (d): currently held and waiting locks per session, object, index, resource type and
mode in the current database, aggregated; plus wait type and blocking session of the request.
- Many KEY locks with mode S for a reading session under READ COMMITTED are unusual:
a hint at locks held until the end of the statement, e.g. nested loops with prefetch ahead of a lookup.
- OBJECT with mode S (instead of IS) means a table lock, for a reader typically after a lock escalation.
Tested in the lock escalation prefetch lab (SQL Server 2019/2022/2025).
*/
SELECT l.request_session_id AS session_id,
s.host_name,
r.wait_type,
r.blocking_session_id,
l.resource_type,
COALESCE(OBJECT_NAME(p.object_id),
CASE WHEN l.resource_type = N'OBJECT'
THEN OBJECT_NAME(l.resource_associated_entity_id) END) AS object_name,
p.index_id,
i.name AS index_name,
l.request_mode,
l.request_status,
COUNT(*) AS lock_count
FROM sys.dm_tran_locks AS l
LEFT JOIN sys.partitions AS p
ON p.hobt_id = l.resource_associated_entity_id
AND l.resource_type IN (N'KEY', N'PAGE', N'RID', N'HOBT')
LEFT JOIN sys.indexes AS i
ON i.object_id = p.object_id
AND i.index_id = p.index_id
LEFT JOIN sys.dm_exec_sessions AS s
ON s.session_id = l.request_session_id
LEFT JOIN sys.dm_exec_requests AS r
ON r.session_id = l.request_session_id
WHERE l.resource_database_id = DB_ID()
AND l.request_session_id <> @@SPID
GROUP BY l.request_session_id, s.host_name, r.wait_type, r.blocking_session_id, l.resource_type,
p.object_id, l.resource_associated_entity_id, p.index_id, i.name,
l.request_mode, l.request_status
ORDER BY session_id, object_name, index_id, resource_type, request_mode;
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| KEY | TestTab2 | ix_ID_Tab1 | S | GRANT | 2000 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab2 | ix_ID_Tab1 | IS | GRANT | 23 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | S | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| KEY | TestTab2 | ix_ID_Tab1 | S | GRANT | 2000 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab2 | ix_ID_Tab1 | IS | GRANT | 23 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | S | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| KEY | TestTab2 | ix_ID_Tab1 | S | GRANT | 1188 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab2 | ix_ID_Tab1 | IS | GRANT | 23 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | S | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
Method and limitations
- SQL Server 2019 CU32 (15.0.4490.9), 2022 CU13 (16.0.4125.3) and 2025 CU7 (17.0.4065.4), each a Developer Edition in its own container on Linux.
- Data based on Fabiano’s setup, generated deterministically and identical in all versions. Synthetic, no production data.
- Counter values and locks measured once per state and repeated at sample points, with identical results. Run times three times each.
- The writers’ wait times depend on the artificial hold and are not production values.
- 2022 only with CU13. We did not examine parallel nested loops plans.
- The escalation threshold applies per thread. In parallel plans, it is distributed differently (Paul White, part 2).
How well do you know your locks?
Every answer is explained right away, with a link to the relevant part of the article. At the end, you get an isolation level and a link you can use to share your score. No cookies.
Further reading
- Craig Freedman, 2007: Read Committed and Bookmark Lookup
- Fabiano Amorim, 2012: SQL Server Prefetch and Query Performance
- Paul White, 2022: The Lock Escalation Threshold, part 1, part 2 and part 3
- LinkedIn 2022: A simple SELECT caused an index lock escalation. Why?
- Microsoft Learn: Resolve blocking problems caused by lock escalation, Optimized locking
- From us: Where does the time go? Query Store with triggers, procedures and functions