Topic page · SQL Server Locking

Enable Optimized Locking? Measure First, Then Decide

Eight sessions update the same table, each its own group of rows. Without a suitable index, they still block each other. With optimized locking, the waiting disappears, and throughput in our lab rises from 69 to 129 changes per second. A success. Then we give the same table the suitable key, without optimized locking at all: 386 changes per second, at one sixth of the CPU.

Updated Nov 11, 2026 · approx. 15 min read

Topic PoC · Measurement · ADR costsSQL query 1Lab SQL Server 2025 CU10
Eight writers, each changing its own rowswithout a suitable index69.3 changes/swithout index, with optimized locking128.7 changes/ssuitable key, without optimized locking386.0 changes/sMedian of three 30-second runs, measured in the PSG lab onSQL Server 2025 CU10.

Optimized locking solves one particular conflict very well. So the question before turning it on is not "Is it good?" but "Do we have this conflict, and is there a cheaper way?" Using measurements from SQL Server 2025, this page shows when the switch pays off, what it costs and how to check that on your own load.

In 60 seconds

  • Optimized locking needs ADR, and its main gain (lock after qualification) exists only with RCSI. Turning it on therefore means turning on three things. The mechanics are explained in Optimized locking explained.
  • The gain: Writers that scan across other sessions' rows no longer block each other. In the lab, almost double the throughput in exactly this case, plus far fewer locks held and hardly any lock escalation.
  • The costs come from ADR: for changes touching many rows, a quarter to two fifths less throughput, 2.3 to 3.5 times the CPU and just over three times as much log. For single-row changes, no difference was measurable.
  • A suitable index was better: three times the throughput of optimized locking without an index, without any of the ADR costs.
  • Hot rows: With many writers on the same row, throughput stayed the same, but the slowest writers became twice as slow.
  • Decide by measuring: measurement window before, switches one at a time, measurement window after. The query for that is below.

What you buy and what you pay

We compared three configurations with four typical loads, 30 seconds each, three repetitions, on SQL Server 2025 CU10 with eight CPUs. We measured successful changes per second:

LoadWriterswithout ADR and RCSIADR + RCSIADR + RCSI + optimized locking
Set-based UPDATE without a suitable index (1,000 rows per change)173.652.964.2
869.348.5128.7
the same change with a suitable key192.271.075.4
8386.0230.5235.0
Single row by primary key1121.8121.9118.5
8584.1589.7639.4
One hot row, 4 writers and 4 readers4129.3135.2134.2

Four things can be read from this:

  • The gain comes from one pattern only. Optimized locking earns its advantage where eight writers without an index run over all rows and wait for each other in the process. Without optimized locking, they waited together for about 205 seconds per 30-second run on LCK_M_U, that is, almost seven sessions at the same time on average. With optimized locking, nobody waited for a lock anymore; the new bottlenecks were page latches and the log.
  • A single writer only pays the cost. Without competition, there is nothing to unblock. What remains is the ADR cost.
  • ADR is expensive for set-based changes. There, ADR + RCSI came in 23 to 40 percent below the configuration with neither, needed 2.3 to 3.5 times the CPU per change and wrote just over three times as much log (412 instead of 129 KB per change with eight writers and a suitable key). The cause: ADR writes a version into the database for every changed row, and that version has to go to the log.
  • Single-row changes don't notice any of this. For changes by primary key, all three configurations were within a few percent of each other, at about one millisecond of CPU per change, limited by writing to the log.
ADR + RCSI48.5 changes/s207 s, 205 s of it locksADR + RCSI + optimized locking128.7 changes/s107 s, no locks050100150200 slockspage latcheslog writesCPU queueTotal wait time of all sessions in a 30-second run, median of three runs. Eight writers each change theirown group of rows via a scan without a suitable index.
ADR + RCSI207 s, 205 s of it locks48.5 changes/sADR + RCSI + optimized locking107 s, no locks128.7 changes/s050100150200 slockspage latcheslog writesCPU queueTotal wait time of all sessions in a 30-secondrun, median of three runs. Eight writers eachchange their own group of rows via a scanwithout a suitable index.
Optimized locking removes the lock waits; the bottleneck moves to page latches and the log. Measured in the PSG lab on SQL Server 2025 CU10, median of three runs.

Then there are the gains that no throughput figure shows: In LAB-10, an UPDATE over 1,000 rows held 1,060 locks with ADR and RCSI and 3 with optimized locking. Fewer locks mean less lock memory and hardly any lock escalation. From telemetry of about 25,000 Azure SQL databases, Microsoft reports that about 81 percent of the locks are skipped for nonclustered indexes. Those are lock counts, not time values; Microsoft does not publish throughput or latency figures, and we found no independent benchmarks.

Does your workload benefit? Five questions

  1. Do writers wait for writers that change other rows? In the waits, this shows up as LCK_M_U, often together with scans in the plans of the waiting UPDATE and DELETE statements. This is the case that optimized locking solves.
  2. Do these changes scan because an index is missing? Then check the index first (next section).
  3. Is there something in the code that prevents the gain? Lock hints such as UPDLOCK, HOLDLOCK or READCOMMITTEDLOCK, isolation levels other than READ COMMITTED, MERGE, OUTPUT to the client, columnstore indexes or plans with multiple accesses to the target table: lock after qualification (LAQ) doesn't apply there.
  4. Do large transactions hold many locks, is there lock escalation? Then optimized locking helps even without waits, because it reduces lock memory and escalations. Exception: Right after a load or a rebuild with fill factor 100, even with optimized locking every first large run escalated in the lab, because the pages split (details).
  5. Is RCSI already on, or acceptable for your application? Without RCSI, little of the gain remains. What RCSI changes in your code is covered in NOLOCK, RCSI or SNAPSHOT?.

If you find nothing at question 1 and nothing at question 4, optimized locking mainly buys you the ADR costs.

Index or optimized locking?

In the lab, it was the same table, the same data and the same statement:

UPDATE dbo.Scan SET v = v + 1 WHERE g = @g;   -- 8,000 rows in eight groups, each session changes its own group

Once with the clustered key on id only, so that every change has to read all rows, and once with the clustered key (g, id), so that every change reads only its own group.

Changes per secondEight writers, medianno key, no ADR or RCSI69.3no key, with optimized locking128.7suitable key, no ADR or RCSI386.0suitable key, with optimized locking235.00100200300400CPU per changeContainer CPU divided by changesno key, no ADR or RCSI8.9 msno key, with optimized locking26.2 mssuitable key, no ADR or RCSI4.3 mssuitable key, with optimized locking10.5 ms0102030 msOrange: with ADR and RCSI, the ADR costs apply. Log per change without ADR 121 or 129 KB, with optimizedlocking 379 or 378 KB.
Changes per secondEight writers, medianno key, no ADR or RCSI69.3no key, with optimized locking128.7suitable key, no ADR or RCSI386.0suitable key, with optimized locking235.00100200300400CPU per changeContainer CPU divided by changesno key, no ADR or RCSI8.9 msno key, with optimized locking26.2 mssuitable key, no ADR or RCSI4.3 mssuitable key, with optimized locking10.5 ms0102030 msOrange: with ADR and RCSI, the ADR costs apply.Log per change without ADR 121 or 129 KB, withoptimized locking 379 or 378 KB.
The suitable key without ADR managed 3.0 times as many changes as optimized locking without an index, at one sixth of the CPU per change. Measured in the PSG lab on SQL Server 2025 CU10, median of three 30-second runs.

The suitable access path removes the conflict at its root: No session reads the other sessions' rows anymore, so none of them waits. Optimized locking only lets the sessions read past each other without waiting; the work of reading other sessions' rows and the ADR costs remain.

That gives the order: access path first, then the switch. Optimized locking is the right choice when you may not change the code or the indexes, for example with vendor software, and the conflict of writers scanning other sessions' rows actually occurs.

The limit of this comparison: We changed the clustered key. An additional nonclustered index on g was not used by the optimizer at a selectivity of one eighth. A real additional index costs space, maintenance and sometimes key lookups; we didn't measure that.

Hot rows: same throughput, longer outliers

Four sessions increment the same counter, four more read it:

95% of writers faster thanper change, median of three runsno ADR or RCSI33.1 msADR + RCSI36.7 msADR + RCSI + optimized locking70.0 ms03570105140 ms99% of writers faster thanper change, median of three runsno ADR or RCSI48.6 msADR + RCSI49.3 msADR + RCSI + optimized locking133.0 ms03570105140 msFour writers increment the same row, four readers read it. Writer throughput the same (129.3 / 135.2 /134.2 changes/s), writer lock wait as well (87 / 86 / 81 s per run); with optimized locking they wait onthe transaction (LCK_M_S_XACT_MODIFY).
95% of writers faster thanper change, median of three runsno ADR or RCSI33.1 msADR + RCSI36.7 msADR + RCSI + optimized locking70.0 ms03570105140 ms99% of writers faster thanper change, median of three runsno ADR or RCSI48.6 msADR + RCSI49.3 msADR + RCSI + optimized locking133.0 ms03570105140 msFour writers increment the same row, fourreaders read it. Writer throughput the same(129.3 / 135.2 / 134.2 changes/s), writer lockwait as well (87 / 86 / 81 s per run); withoptimized locking they wait on the transaction(LCK_M_S_XACT_MODIFY).
Same throughput, but longer outliers: with optimized locking the slowest five percent of writers took 1.9 times as long as with ADR and RCSI alone. Measured in the PSG lab on SQL Server 2025 CU10, one synthetic case.

The writers' throughput stays the same, and so does their total wait time. With optimized locking, though, it is distributed more unevenly: The slowest five percent of the writers took twice as long, the slowest one percent almost three times as long. For applications with response-time commitments on such counters, sequence tables or status rows, that is a reason to measure beforehand. Microsoft doesn't describe this; in the sources we found only an experience report without measurements.

Our assumption about the cause, not verified: With optimized locking, a writer waits for the other writer's transaction, not for the row. After that transaction commits, it has to request the row again, and the ordered queue of a classic row lock doesn't exist there.

As a side note, the readers show what RCSI does: 47 times as many queries, with and without optimized locking. Whoever wants to relieve readers needs RCSI, not optimized locking.

Measure first: the measurement recipe

You judge a change best with the same measurement before and after, at the same time of load. The following query measures a time window: It remembers the counters, waits the configured time and then shows what happened in that window. Lock waits appear including the new LCK_M_S_XACT_* types, plus log and version store.

Query: Measurement window before and after

Query 4: Measurement window before and after · read-only · tested in the PSG lab, versions in the header
/* PSG Stack · SQL Server Locking · Query 4   Measurement window: What happened during a fixed time window? Lock waits (including the new XACT wait types),   lock and deadlock counters, log written and version store.   Run once before and once after a change, at a comparable time of load, and compare.    !!! WARNING — please read before running !!!   - Read-only. The query changes no data and no setting. It only creates small table     variables in your own session.   - It WAITS on purpose: the session stays open for @Seconds (WAITFOR DELAY) and returns     results afterwards. Cancelling is safe at any time.   - Waits and counters are server-wide; log and version store are measured only for the     database you run the query in. Other workload during the window is included.   - Permissions: VIEW SERVER STATE (from SQL Server 2022, VIEW SERVER PERFORMANCE STATE is enough),     plus a user in the database you measure.   - MAXDOP 1: every part runs serially and uses no additional cores.   - SQL Server 2017 and later. What a version does not know (version store DMV before 2019,     optimized locking before 2025) shows as n/a. Tested in the PSG lab on 2017, 2019, 2022 and 2025     (Nov 10, 2026), under load on 2025 CU10, also without sysadmin.    Results:     1  Switches of the current database (RCSI, ADR, optimized locking)     2  Waits in the window: locks, log and page latches only, largest first     3  Counters in the window: lock requests, lock waits, timeouts, deadlocks, escalations     4  Log and version store of the current database*/SET NOCOUNT ON; DECLARE @Seconds int = 60;   -- length of the measurement window in seconds (1 to 3600) DECLARE @w0 TABLE (wait_type nvarchar(60) PRIMARY KEY, waits bigint, ms bigint, signal_ms bigint);DECLARE @c0 TABLE (counter_name nvarchar(128) PRIMARY KEY, value bigint);DECLARE @w1 TABLE (wait_type nvarchar(60) PRIMARY KEY, waits bigint, ms bigint, signal_ms bigint);DECLARE @c1 TABLE (counter_name nvarchar(128) PRIMARY KEY, value bigint);DECLARE @log0 bigint, @log1 bigint, @pvs0 bigint, @pvs1 bigint, @t0 datetime2(3), @t1 datetime2(3);DECLARE @hasPvs bit = CASE WHEN OBJECT_ID(N'sys.dm_tran_persistent_version_store_stats') IS NULL THEN 0 ELSE 1 END;DECLARE @pvsSql nvarchar(400) = N'SELECT @p = persistent_version_store_size_kb FROM sys.dm_tran_persistent_version_store_stats WHERE database_id = DB_ID() OPTION (MAXDOP 1);';DECLARE @delay char(8); IF @Seconds NOT BETWEEN 1 AND 3600 BEGIN RAISERROR(N'@Seconds must be between 1 and 3600.', 16, 1); RETURN; END;SET @delay = CONVERT(char(8), DATEADD(second, @Seconds, CONVERT(datetime, 0)), 108); /* Before */SET @t0 = SYSUTCDATETIME();INSERT @w0SELECT wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_msFROM sys.dm_os_wait_statsWHERE wait_type LIKE N'LCK[_]M[_]%' OR wait_type IN (N'WRITELOG', N'PAGELATCH_EX', N'PAGELATCH_SH')OPTION (MAXDOP 1);INSERT @c0SELECT RTRIM(counter_name), cntr_valueFROM sys.dm_os_performance_countersWHERE (object_name LIKE N'%:Locks%' AND instance_name = N'_Total'       AND counter_name IN (N'Lock Requests/sec', N'Lock Waits/sec', N'Lock Timeouts (timeout > 0)/sec', N'Number of Deadlocks/sec'))   OR (object_name LIKE N'%:Access Methods%' AND counter_name = N'Table Lock Escalations/sec')OPTION (MAXDOP 1);SELECT @log0 = SUM(v.num_of_bytes_written)FROM sys.dm_io_virtual_file_stats(DB_ID(), NULL) AS vJOIN sys.database_files AS f ON f.file_id = v.file_id AND f.type = 1OPTION (MAXDOP 1);IF @hasPvs = 1 EXEC sys.sp_executesql @pvsSql, N'@p bigint OUTPUT', @p = @pvs0 OUTPUT; WAITFOR DELAY @delay; /* After */SET @t1 = SYSUTCDATETIME();INSERT @w1SELECT wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_msFROM sys.dm_os_wait_statsWHERE wait_type LIKE N'LCK[_]M[_]%' OR wait_type IN (N'WRITELOG', N'PAGELATCH_EX', N'PAGELATCH_SH')OPTION (MAXDOP 1);INSERT @c1SELECT RTRIM(counter_name), cntr_valueFROM sys.dm_os_performance_countersWHERE (object_name LIKE N'%:Locks%' AND instance_name = N'_Total'       AND counter_name IN (N'Lock Requests/sec', N'Lock Waits/sec', N'Lock Timeouts (timeout > 0)/sec', N'Number of Deadlocks/sec'))   OR (object_name LIKE N'%:Access Methods%' AND counter_name = N'Table Lock Escalations/sec')OPTION (MAXDOP 1);SELECT @log1 = SUM(v.num_of_bytes_written)FROM sys.dm_io_virtual_file_stats(DB_ID(), NULL) AS vJOIN sys.database_files AS f ON f.file_id = v.file_id AND f.type = 1OPTION (MAXDOP 1);IF @hasPvs = 1 EXEC sys.sp_executesql @pvsSql, N'@p bigint OUTPUT', @p = @pvs1 OUTPUT; /* 1 · Switches of the current database */DECLARE @switches nvarchar(max) = N'SELECT d.name AS database_name,       d.is_read_committed_snapshot_on AS rcsi,       d.snapshot_isolation_state_desc AS snapshot_isolation,       ' + CASE WHEN COL_LENGTH(N'sys.databases', N'is_accelerated_database_recovery_on') IS NULL                THEN N'N''n/a''' ELSE N'CONVERT(nvarchar(3), d.is_accelerated_database_recovery_on)' END + N' AS adr,       ' + CASE WHEN COL_LENGTH(N'sys.databases', N'is_optimized_locking_on') IS NULL                THEN N'N''n/a''' ELSE N'CONVERT(nvarchar(3), d.is_optimized_locking_on)' END + N' AS optimized_lockingFROM sys.databases AS dWHERE d.database_id = DB_ID()OPTION (MAXDOP 1);';EXEC sys.sp_executesql @switches; /* 2 · Waits in the window */SELECT w1.wait_type,       CASE WHEN w1.wait_type LIKE N'%XACT%' THEN N'optimized locking (XACT)'            WHEN w1.wait_type LIKE N'LCK%' THEN N'lock'            WHEN w1.wait_type = N'WRITELOG' THEN N'log write'            ELSE N'page latch' END AS kind,       w1.waits - ISNULL(w0.waits, 0) AS waits,       w1.ms - ISNULL(w0.ms, 0) AS wait_ms,       CONVERT(decimal(12, 2), (w1.ms - ISNULL(w0.ms, 0)) * 1.0 / NULLIF(w1.waits - ISNULL(w0.waits, 0), 0)) AS ms_per_wait,       CONVERT(decimal(12, 2), (w1.ms - ISNULL(w0.ms, 0)) / 1000.0 / NULLIF(DATEDIFF(millisecond, @t0, @t1) / 1000.0, 0)) AS avg_waitingFROM @w1 AS w1LEFT JOIN @w0 AS w0 ON w0.wait_type = w1.wait_typeWHERE w1.waits > ISNULL(w0.waits, 0)ORDER BY wait_ms DESCOPTION (MAXDOP 1); /* 3 · Counters in the window */SELECT c1.counter_name,       c1.value - ISNULL(c0.value, 0) AS in_window,       CONVERT(decimal(14, 2), (c1.value - ISNULL(c0.value, 0)) / NULLIF(DATEDIFF(millisecond, @t0, @t1) / 1000.0, 0)) AS per_secondFROM @c1 AS c1LEFT JOIN @c0 AS c0 ON c0.counter_name = c1.counter_nameORDER BY c1.counter_nameOPTION (MAXDOP 1); /* 4 · Log and version store of the current database */SELECT DB_NAME() AS database_name,       DATEDIFF(millisecond, @t0, @t1) / 1000.0 AS window_s,       CONVERT(decimal(14, 2), (@log1 - @log0) / 1048576.0) AS log_written_mb,       CASE WHEN @hasPvs = 0 THEN N'n/a' ELSE CONVERT(nvarchar(20), @pvs0) END AS pvs_before_kb,       CASE WHEN @hasPvs = 0 THEN N'n/a' ELSE CONVERT(nvarchar(20), @pvs1) END AS pvs_after_kbOPTION (MAXDOP 1);

Sample output

A 20-second window from the lab (SQL Server 2025 CU10, eight writers without a suitable index, ADR, RCSI and optimized locking on, synthetic data, run as a login with only VIEW SERVER PERFORMANCE STATE):

Sample output
1 · Options
database_name  rcsi  snapshot_isolation  adr  optimized_locking
-------------  ----  ------------------  ---  -----------------
ol_full        1     OFF                 1    1

2 · Waits in the window
wait_type     kind        waits  wait_ms  ms_per_wait  avg_waiting
------------  ----------  -----  -------  -----------  -----------
PAGELATCH_SH  page latch  43157    46799         1.08         2.33
PAGELATCH_EX  page latch  20105    24980         1.24         1.24
WRITELOG      log write    2468     7411         3.00         0.37

3 · Counters in the window
counter_name                     in_window  per_second
-------------------------------  ---------  ----------
Lock Requests/sec                  6874207   342204.65
Lock Timeouts (timeout > 0)/sec          0        0.00
Lock Waits/sec                           0        0.00
Number of Deadlocks/sec                  0        0.00
Table Lock Escalations/sec               0        0.00

4 · Log and version store
database_name  window_s   log_written_mb  pvs_before_kb  pvs_after_kb
-------------  ---------  --------------  -------------  ------------
ol_full        20.088000          913.58         769416        348488

How to read it:

  • Result 2: Not a single lock wait, but page latches and log. In our runs of the same load without optimized locking, almost seven sessions on average were waiting on LCK_M_U at this point. The bottleneck has moved; that is the gain. If new LCK_M_S_XACT_* rows appear after switching on, sessions are waiting for other transactions, for instance on hot rows.
  • Result 3: Lock Waits and Table Lock Escalations should drop after the change. The number of lock requests doesn't necessarily drop, because short row locks still exist.
  • Result 4: The log and the version store show the ADR costs. If the log per window rises markedly, check space, backup and replication. The version store may fluctuate as long as it shrinks again; if it grows from window to window, something is holding up cleanup.

In addition, every before-and-after measurement should include the response times of the most important operations from the application's point of view, including the slowest five percent, and the CPU of the instance. The extended event locking_stats reports escalations and how often lock after qualification was attempted, per database about every five minutes; so it also shows whether the gain applies at all.

The PoC in five steps

  1. A test system with real load. A replay or a load test with the operations that wait today. On an empty system you see only the costs. SQL Server 2025 with at least CU9: Microsoft fixed crashes with optimized locking in combination with change tracking (CU3) and with forwarded records in heaps (CU9).
  2. A measurement window with nothing switched on. Query 4 at the load times, plus response times and CPU.
  3. Switch on one at a time. First ADR, measure. Then RCSI, measure. Then optimized locking, measure. Only this way do you know which step costs what and brings what. In our lab, ADR + RCSI cost up to 40 percent, and only optimized locking more than made up for it in the suitable case.
  4. Check results, not only times. RCSI changes what triggers, validation logic and read-then-write code see; optimized locking changes the order of changes in one edge case. Both are covered in NOLOCK, RCSI or SNAPSHOT? and Optimized locking explained. Test these spots with two sessions.
  5. Check monitoring and deadlock analysis. Does your monitoring recognize the wait types LCK_M_S_XACT_READ, LCK_M_S_XACT_MODIFY and LCK_M_S_XACT? Does your deadlock analysis read the <xactlock> element? If not, blocking looks as if it had disappeared after the switch.

How to recognize a regression

SignLikely causeWhat you do
Throughput drops, CPU and log rise for set-based changesADR costs without a matching conflictLook at the measurement from step 3; turn it back off if there is no gain
Version store grows from window to windowA long transaction, a long query or a forgotten open transaction holds up cleanupFind the holder in sys.dm_tran_persistent_version_store_stats (oldest_active_transaction_id); Version Store and Transaction Log in SQL Server
The slowest writers get slower, throughput the sameHot rows, new LCK_M_S_XACT_MODIFY waitsDefuse the hotspot (distribution, sequences) or turn optimized locking off for this database
Lock escalation despite optimized lockingFirst change of freshly loaded rows on full pagesExpected after large loads; check the fill factor (details)
lock_after_qual_stmt_abort frequent, run time of individual changes fluctuatesLock after qualification restarts statementsThe heuristic turns LAQ off by itself when too much work is wasted, per plan with Query Store, otherwise for the whole database; leave Query Store on
Blocking dashboard stays empty, users complainMonitoring doesn't know the XACT wait typesExtend the filter to LCK_M_S_XACT*

Switching in production

All three switches need a moment in which no other connection is active in the database:

ALTER DATABASE [MyDatabase] SET ACCELERATED_DATABASE_RECOVERY = ON WITH ROLLBACK IMMEDIATE;ALTER DATABASE [MyDatabase] SET READ_COMMITTED_SNAPSHOT = ON WITH ROLLBACK IMMEDIATE;ALTER DATABASE [MyDatabase] SET OPTIMIZED_LOCKING = ON WITH ROLLBACK IMMEDIATE;

What we saw in the lab on SQL Server 2017 to 2025:

  • Without an option, the statement waits, for every other connection in the database, even an idle one without a transaction. If the other session had a transaction, ALTER DATABASE waited beyond its commit until the connection was closed.
  • WITH NO_WAIT didn't help. According to the documentation, the statement is supposed to fail immediately with it. In the lab, on all versions and for all three switches, it waited as long as without the option, without any message. We haven't established the cause.
  • WITH ROLLBACK IMMEDIATE disconnects the other connections and rolls back open transactions. In the lab, the switch was flipped after about three seconds.

So plan a maintenance window in which aborts are acceptable. Going back works in reverse order with OFF: first optimized locking, then RCSI as needed, then ADR. The other way round, SQL Server refuses (Msg 12134, see Optimized locking explained). Optimized locking is available in SQL Server 2025 Enterprise and Standard editions, not in Express; in Azure SQL Database it is always on.

What your tools don't know yet

Optimized locking changes what diagnostic tools get to see: new wait types, locks on XACT instead of rows, a new element in the deadlock report. Some community tools already know this, others don't yet, and the state changes with every version. So check your own monitoring before you switch: Does it show a session waiting on LCK_M_S_XACT_MODIFY as blocked? Does it resolve the blocker? Does it evaluate a deadlock with <xactlock>? The easiest way to test this is with the lock and switch overview from the overview page and two sessions on a test system.

Method and limits

The workload measurement ran on Nov 10, 2026 on SQL Server 2025 CU10 (17.0.5035.5), Developer Edition in a Linux container with eight CPUs, 12 GB of memory for SQL Server and MAXDOP 1, data and log on a shared SATA SSD. 63 runs of 30 seconds each, three repetitions per data point in random order, medians; all runs without errors, deadlocks or table escalations, and after each run the totals matched the successful changes. Eight runs overlapped with another experiment on the same host; repeated alone, they came out 3 percent lower to 14 percent higher, which changed nothing about the conclusions. We measured switching in a separate experiment on SQL Server 2017 CU31, 2019 CU32, 2022 CU27 and 2025 CU10. Limits:

  • Synthetic loads, a closed load loop without an arrival rate, one host. The log was the bottleneck in several cases and limits all configurations equally; on faster storage, the gaps can turn out differently.
  • No long-term measurement: We didn't measure how the version store and cleanup behave over hours with a long read transaction.
  • The cause of the longer outliers on hot rows is an assumption.
  • We made the index comparison with a different clustered key; the cost of a real additional index is not measured.
  • We didn't measure Azure SQL Database, Managed Instance or Windows.
  • Percentages apply to these loads. They are not a forecast for your system.

Further reading

Planning the upgrade to SQL Server 2025?

Then the question of optimized locking comes up anyway. In a performance analysis, we measure on your load whether the conflict that optimized locking solves occurs, whether an index removes it more cheaply and what ADR costs you. The SQL Server Health Check gives the overall view of the instance.

More locking topics