From the lab · Locking

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.

7 October 2026 · approx. 12 minutes

Measured SQL Server 2019 · 2022 · 2025Freeze points down to the rowCountermeasures 12 testedPlay along 10 questions ↓

The case in 30 seconds

  1. Parameter, first call@p1 = 0.8A plan for a few rows. The first call with 0.8 produces two nested loops with prefetch and a key lookup.
  2. Parameter, second call@p1 = 800Executed with all rows. With 800, the same plan reads all 10,000 rows of TestTab2. For every row from ix_ID_Tab1, an S lock stays in place.
  3. 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”.
  4. Writers’ wait typeLCK_M_IXThen every writer waits. INSERT, UPDATE and DELETE on TestTab2 wait with LCK_M_IX until 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 char columns 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:

Procedure dbo.p2
CREATE 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;
Two calls, one plan
EXEC dbo.p2 0.8;   -- puts the plan into the cache: 37 rows
EXEC dbo.p2 800;   -- keeps using it: 10,000 rows

The 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.

Interactive · The plan in the cache Plan after 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.

Actual execution plan · rows estimated → actual…

Compiled for
–
Estimated rows
–
Actual at 800
–
Escalated locks (XE)
–
Shown as in the compendium Understanding the optimizer: result on the left, data access on the right, arrow width shows the rows. On the inner side of a nested loops, the estimate is per execution. Select operators with the mouse, a finger or the Tab key.
Interactive · Freeze and count

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_Tab1 has 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 attempt counter already rises at checkpoints where nothing can escalate. In our runs, index_lock_promotion_attempt_count rose when 2,500, 3,750 and 5,000 locks were passed, by 4 each time in 2019 and 2022. index_lock_promotion_count rises 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.

Index Seekix_ID_Tab1collect pointersPrefetchreads aheadgapKey LookupPK_TestTab2
The S lock on the seek row stays in place until the statement ends

This is not a bug but by design:

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.

Interactive · Blocker → reader → writer

What the escalated reader lock does

Writer
Phase

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 of Col1 still run through, because they do not touch that index. Only a DELETE of a row already read waits with LCK_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.

Interactive · The first call decides

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.

Interactive · Countermeasure lab

Countermeasures tested

✓ no escalation ✗ escalates – not measured
Escalation on EXEC p2 800, four runs per version. “None” means: no escalation.
Variant201920222025Assessment
Baselineescalatesescalatesescalates
RCSInonenonenoneReaders take no S locks. Plan for the version store in tempdb, and review code that relies on blocking reads.
SNAPSHOT (session)nonenonenoneReaders take no S locks, as with RCSI. Requires ALLOW_SNAPSHOT_ISOLATION, the session opts in; update conflicts are possible.
Covering index (INCLUDE (Col1))nonenonenoneno lookup, no held locks. More space and more write overhead.
OPTION (RECOMPILE)nonenonenoneCompiled for the current value. Compilation cost, and here a slower parallel plan.
QUERYTRACEON 8744nonenoneescalatesin 2025, the batch sort holds the locks. Documented only for global/session scope.
8744 + DISABLE_OPTIMIZED_NESTED_LOOPnonenonenoneonly both together help in 2025
LOCK_ESCALATION = DISABLEnonenonenonebut 8,001 (2025: 7,555) individual locks held at the same point. The symptom just moves.
NOLOCKnonenonenonenot recommended: dirty reads, missing or duplicate rows
Optimized locking, RCSI off––escalatesacts on writers, not on these reader locks
Optimized locking + RCSI––nonethe effect comes from RCSI

Our order in practice:

  1. Get rid of the lookup if the query matters. A covering index or a narrower column list.
  2. Consider RCSI if the application can handle it. Many can, some cannot.
  3. 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

Measured with SQL Server 2019 CU32 (15.0.4490.9), 2022 CU13 (16.0.4125.3) and 2025 CU7 (17.0.4065.4).
2019 CU322022 CU132025 CU7
Plan after 0.82 × NL with Prefetch + Key Lookupsamesame, plus batch sort
Escalation at 800yes, 6,248 locksyes, 6,248yes, 6,247
Last state without escalation6,220 key locks6,2205,109 (batch sort collects in blocks)
XE reports “lock class”field missingtruetrue
TF 8744 helpsyesyesno, only with DISABLE_OPTIMIZED_NESTED_LOOP
Optimized locking––helps only with RCSI
Plan stays stableyesone verification runreplaced 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.

a

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.
diagnose/a-escalation-zaehler.sql28 lines
/*
  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;
What it looked like in the lab
Difference between two runs of the query before and after one call EXEC dbo.p2 800, selected columns.
table_nameindex_nameΔ index_lock_promotion_attempt_countΔ index_lock_promotion_count
TestTab2ix_ID_Tab1131
TestTab2PK_TestTab2130
TestTab1ix_Col470
b

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.
diagnose/b-plancache-prefetch-lookup.sql35 lines
/*
  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;
What it looked like in the lab
Matches for dbo.p2 after EXEC dbo.p2 0.8, selected columns.
nested_loops_node_idprefetchouter_estimate_rowsinner_lookup_tableinner_lookup_index
0unordered273.045[TestTab2][PK_TestTab2]
2unordered117.846NULLNULL
c

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 = OFF and does not start on its own after a restart.
  • Do not add events such as lock_acquired or lock_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.
Part 1 · Create sessiondiagnose/c-xe-lock-escalation-session.sql33 lines
/*
  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;
*/
Part 2 · Read eventsdiagnose/c-xe-lock-escalation-read.sql37 lines
/*
  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;
What it looked like in the lab
Event after EXEC dbo.p2 800, selected columns (without time, IDs, statement and SQL text).
object_nameresource_typemodeescalation_causeescalated_lock_counthobt_lock_countwith_lock_classclient_hostname
TestTab2OBJECTSLock threshold62486245NULLLESC-RDR
d

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.
diagnose/d-gehaltene-locks.sql38 lines
/*
  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;
What it looked like in the lab
Session of the frozen reader before the escalation, selected columns.
resource_typeobject_nameindex_namerequest_moderequest_statuslock_countwait_type
DATABASENULLNULLSGRANT1LCK_M_S
KEYTestTab1ix_Col4SWAIT1LCK_M_S
KEYTestTab2ix_ID_Tab1SGRANT2000LCK_M_S
OBJECTTestTab1NULLISGRANT1LCK_M_S
OBJECTTestTab2NULLISGRANT1LCK_M_S
PAGETestTab1ix_Col4ISGRANT1LCK_M_S
PAGETestTab2ix_ID_Tab1ISGRANT23LCK_M_S
The same session after the escalation: the key locks on ix_ID_Tab1 have given way to a table lock (OBJECT, S).
resource_typeobject_nameindex_namerequest_moderequest_statuslock_countwait_type
DATABASENULLNULLSGRANT1LCK_M_S
KEYTestTab1ix_Col4SWAIT1LCK_M_S
OBJECTTestTab1NULLISGRANT1LCK_M_S
OBJECTTestTab2NULLSGRANT1LCK_M_S
PAGETestTab1ix_Col4ISGRANT1LCK_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).
Quiz · 10 questions

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