<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Master level
All lessons in this course

Lesson 49 of 54 20 minMaster

Isolation levels, locking and blocking

What happens when two sessions use the same rows at once: shared and exclusive locks, blocking and how to find the blocker, dirty, non-repeatable and phantom reads, the four isolation levels, lock escalation, lost updates and UPDLOCK, and LOCK_TIMEOUT.

So far you have been the only user of BuildMart. A real shop has many sessions at once: the website, the till, a stock report. The I in ACID promises that each transaction behaves as if it had the database to itself. SQL Server keeps that promise mainly with locks, and this lesson shows what they cost.

Locks a transaction holds

Before a session changes a row it takes an exclusive (X) lock on it, and it keeps the lock until its transaction ends. sys.dm_tran_locks lists the locks every session holds or is waiting for:

Query.sql
BEGIN TRANSACTION; UPDATE dbo.Products SET Price = 95.00 WHERE ProductId = 2; SELECT resource_type, request_mode, request_statusFROM sys.dm_tran_locksWHERE request_session_id = @@SPID  AND resource_type <> 'DATABASE'ORDER BY resource_type; ROLLBACK;
Output
resource_type  request_mode  request_status
-------------  ------------  --------------
KEY            X             GRANT
OBJECT         IX            GRANT
PAGE           IX            GRANT

The KEY lock is on the row itself (a row in an index is identified by its key). The intent locks (IX) on its page and on the table are signposts: they tell other sessions “someone holds an X lock somewhere inside” without making them check every row. A request to lock the whole table sees the IX at once and waits.

ModeTaken byAllows others to
S (shared)Reading a rowRead it too (S), not change it (X)
U (update)Finding a row you are about to changeRead it (S), not take another U or X
X (exclusive)Changing a rowNothing: readers and writers wait
IS, IX (intent)Higher levels (page, table) above S or X locksLock other rows; blocks only a whole-table lock that conflicts

Blocking

When a session asks for a lock that conflicts with one another session holds, it waits. That is blocking. Session 1 changes the drill’s price and leaves its transaction open:

Session 1
BEGIN TRANSACTION;UPDATE dbo.Products SET Price = 95.00 WHERE ProductId = 2;

Session 2 reads the same row. It needs an S lock, the row has an X lock, so the query does not return: SSMS shows “Executing query…” and the clock keeps running.

Session 2
SELECT Name, Price FROM dbo.Products WHERE ProductId = 2;

From a third window you can see who is waiting for whom. sys.dm_exec_requests shows each running request and the session blocking it:

Session 3
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.command,       t.text AS waiting_sqlFROM sys.dm_exec_requests AS rCROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS tWHERE r.blocking_session_id <> 0; SELECT resource_type, request_mode, request_status, request_session_idFROM sys.dm_tran_locksWHERE resource_database_id = DB_ID() AND resource_type <> 'DATABASE'ORDER BY request_session_id, resource_type;
Output
session_id  blocking_session_id  wait_type  command  waiting_sql
----------  -------------------  ---------  -------  ----------------------------------------------------------------------------
        54                   51  LCK_M_S    SELECT   (@1 tinyint)SELECT [Name],[Price] FROM [dbo].[Products] WHERE [ProductId]=@1

resource_type  request_mode  request_status  request_session_id
-------------  ------------  --------------  ------------------
KEY            X             GRANT                           51
OBJECT         IX            GRANT                           51
PAGE           IX            GRANT                           51
KEY            S             WAIT                            54
OBJECT         IS            GRANT                           54
PAGE           IS            GRANT                           54

Session 54 is waiting (LCK_M_S: waiting for a shared lock) on session 51. The lock list shows the conflict exactly: 51 has been granted X on the key and 54 is waiting for S on it. The waiting query text also shows that SQL Server turned the literal 2 into a parameter (@1) so the plan can be reused.

As soon as Session 1 commits, its locks go and Session 2’s SELECT returns, with the new price:

Session 1
COMMIT;
Session 2 (returns now)
SELECT Name, Price FROM dbo.Products WHERE ProductId = 2;
Output
Name            Price
--------------  -----
Cordless drill  95.00

Three things that can go wrong

Without isolation, a transaction reading data that another transaction is changing can get three kinds of wrong answer:

ProblemWhat the reader sees
Dirty readA change that is not committed yet, and may be rolled back
Non-repeatable readThe same row read twice in one transaction gives two different values, because another transaction changed it in between
Phantom readThe same query run twice returns a different set of rows, because another transaction inserted (or deleted) rows that match

The isolation level of a session decides which of these it is protected from, and the price is more blocking. Set it with SET TRANSACTION ISOLATION LEVEL; it lasts until the session changes it.

Interactive

Which problems does each level stop?

Session 1 (reader)Session 2 (writer)
Session 2
BEGIN TRANSACTION;
UPDATE dbo.Products SET Price = 9.99
WHERE ProductId = 2;
Session 1
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT Price FROM dbo.Products
WHERE ProductId = 2;
waits for the other session’s lock…
Session 2
ROLLBACK;
Session 1
(the waiting SELECT finishes)
after the rollback → 89.00
Prevented by waiting: Session 1 needed an S lock on a row with an X lock, so it waited until the rollback and then read 89.00.

READ UNCOMMITTED and NOLOCK: dirty reads

At READ UNCOMMITTED a reader takes no S locks, so it is never blocked, and it reads whatever is in the row, committed or not. Session 1 starts a price change it will cancel:

Session 1
BEGIN TRANSACTION;UPDATE dbo.Products SET Price = 9.99 WHERE ProductId = 2;
Session 2
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;SELECT Name, Price FROM dbo.Products WHERE ProductId = 2;
Output
Name            Price
--------------  -----
Cordless drill   9.99
Session 1
ROLLBACK;

Session 2 has told someone the drill costs 9.99. That price never existed: it was rolled back. The table hint WITH (NOLOCK) does the same for one table in one query, and gave the same 9.99 here.

READ COMMITTED: the default

At READ COMMITTED a reader takes an S lock on each row as it reads it and releases it straight away. So it never sees uncommitted data (it waits, as you saw above), but nothing stops the row changing between two reads in the same transaction:

Session 1
BEGIN TRANSACTION;SELECT Name, Stock FROM dbo.Products WHERE ProductId = 2;
Output
Name            Stock
--------------  -----
Cordless drill     12
Session 2
UPDATE dbo.Products SET Stock = Stock - 10 WHERE ProductId = 2;
Session 1
SELECT Name, Stock FROM dbo.Products WHERE ProductId = 2;COMMIT;
Output
Name            Stock
--------------  -----
Cordless drill      2

A non-repeatable read. If Session 1 had checked “12 in stock, enough for 5” and then sold 5, it would be selling stock that is no longer there.

REPEATABLE READ: keep the read locks

REPEATABLE READ keeps every S lock until the transaction ends. Run the same steps with Session 1 at REPEATABLE READ: Session 2’s UPDATE now waits for Session 1, Session 1 reads 12 both times, and the UPDATE goes through after Session 1 commits.

It locks only the rows it has read, though. A new row that matches the query can still be inserted:

Session 1
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;BEGIN TRANSACTION;SELECT COUNT(*) AS Tools FROM dbo.Products WHERE CategoryId = 1;
Output
Tools
-----
    3
Session 2
INSERT INTO dbo.Products (Name, CategoryId, Price, Stock, Unit, AddedOn)VALUES (N'Spirit level', 1, 14.00, 20, N'each', '2024-07-01');
Session 1
SELECT COUNT(*) AS Tools FROM dbo.Products WHERE CategoryId = 1;COMMIT;
Output
Tools
-----
    4

The spirit level is a phantom: a row that appeared inside Session 1’s transaction.

SERIALIZABLE: lock the ranges too

SERIALIZABLE locks the range a query covered, not just the rows it found, so no one can insert into it. With the same steps at SERIALIZABLE, Session 2’s INSERT waits and Session 1 counts 3 tools both times. You can see range locks from a single session. This query reads products 2 to 4 by the primary key:

Query.sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;BEGIN TRANSACTION; SELECT ProductId, Name FROM dbo.Products WHERE ProductId BETWEEN 2 AND 4; SELECT resource_type, request_mode, COUNT(*) AS LocksFROM sys.dm_tran_locksWHERE request_session_id = @@SPID AND resource_type <> 'DATABASE'GROUP BY resource_type, request_modeORDER BY resource_type; ROLLBACK;SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Output
ProductId  Name
---------  ----------------
        2  Cordless drill
        3  Tape measure 8 m
        4  Cement 25 kg

resource_type  request_mode  Locks
-------------  ------------  -----
KEY            RangeS-S          4
OBJECT         IS                1
PAGE           IS                1

RangeS-S is a shared lock on a key and the gap before it. There are 4: on keys 2, 3 and 4, and on key 5, which closes the gap after 4 so nothing can be inserted between 4 and 5. Without an index to lock ranges on, SERIALIZABLE has to lock much more, often the whole table.

Isolation levelDirty readNon-repeatable readPhantomReaders hold S locks
READ UNCOMMITTEDpossiblepossiblepossiblenone
READ COMMITTED (default)preventedpossiblepossibleonly while reading the row
REPEATABLE READpreventedpreventedpossibleuntil the transaction ends
SERIALIZABLEpreventedpreventedpreventeduntil the end, on key ranges

Lock escalation

Every lock costs memory. When one statement holds more than 5,000 locks on one table, SQL Server swaps them for a single table lock: lock escalation. It saves memory, but a table X lock blocks every other session’s access to the whole table:

Query.sql
DROP TABLE IF EXISTS dbo.Sales;CREATE TABLE dbo.Sales (SaleId int NOT NULL PRIMARY KEY, Amount decimal(10,2) NOT NULL);INSERT INTO dbo.Sales (SaleId, Amount)SELECT value, 10.00 FROM GENERATE_SERIES(1, 10000);GOBEGIN TRANSACTION;UPDATE dbo.Sales SET Amount += 1 WHERE SaleId <= 4000; SELECT resource_type, request_mode, COUNT(*) AS LocksFROM sys.dm_tran_locksWHERE request_session_id = @@SPID AND resource_type <> 'DATABASE'GROUP BY resource_type, request_modeORDER BY resource_type;ROLLBACK; BEGIN TRANSACTION;UPDATE dbo.Sales SET Amount += 1 WHERE SaleId <= 7000; SELECT resource_type, request_mode, COUNT(*) AS LocksFROM sys.dm_tran_locksWHERE request_session_id = @@SPID AND resource_type <> 'DATABASE'GROUP BY resource_type, request_modeORDER BY resource_type;ROLLBACK;
Output
resource_type  request_mode  Locks
-------------  ------------  -----
KEY            X              4000
OBJECT         IX                1
PAGE           IX               11

resource_type  request_mode  Locks
-------------  ------------  -----
OBJECT         X                 1

4,000 rows: 4,000 key locks, plus intent locks on 11 pages and the table. 7,000 rows: one X lock on the whole table and nothing else. The documented trigger is 5,000 locks, but SQL Server only checks now and then as locks are added: on this table it still held 6,200 row locks, and had escalated by 6,300. If big updates block everyone, do them in batches of a few thousand rows, each in its own transaction.

Lost updates

A common bug in application code: read a value, work out the new value, write it back. Two sessions run this at the same time to sell one drill each (the WAITFOR stands for the time the application takes between its two statements):

Session 1 and Session 2, at the same time
BEGIN TRANSACTION;DECLARE @Stock int = (SELECT Stock FROM dbo.Products WHERE ProductId = 2);WAITFOR DELAY '00:00:02';UPDATE dbo.Products SET Stock = @Stock - 1 WHERE ProductId = 2;COMMIT;
Session 3, afterwards
SELECT Name, Stock FROM dbo.Products WHERE ProductId = 2;
Output
Name            Stock
--------------  -----
Cordless drill     11

Two drills sold, one taken out of stock. Both sessions read 12 and both wrote 11: the second write overwrote the first. That is a lost update, and READ COMMITTED does not prevent it, because the S lock is released as soon as the row has been read.

The best fix is to avoid the separate read. One UPDATE that does the arithmetic itself is atomic, and it can check the stock in the same statement:

Query.sql
UPDATE dbo.ProductsSET Stock = Stock - 1OUTPUT inserted.Name, deleted.Stock AS Before, inserted.Stock AS AfterWHERE ProductId = 2 AND Stock >= 1;
Output
Name            Before  After
--------------  ------  -----
Cordless drill      12     11

When you really must read first, read WITH (UPDLOCK). It takes an update lock and keeps it to the end of the transaction. Plain readers are not blocked, but a second UPDLOCK reader waits. With UPDLOCK in both sessions above, Session 2 waited, read 11, and the drill ended at 10, which is right.

Quick check

Session 1 has updated a row and not committed. Session 2, at the default READ COMMITTED level, selects that row. What happens?

Quick check

What is the lowest isolation level that stops another session inserting a row that your transaction’s query would now return?

Practice

Sell 5 boxes of wood screws (product 8) safely in one statement: take 5 off Stock only if at least 5 are in stock, and return the name with the stock before and after. Then try to sell 5 boxes of wall plugs (product 9, none in stock) the same way, and print 'Out of stock' when no row was changed.

Show a hint

Put the stock check in the WHERE clause and use OUTPUT deleted.Stock, inserted.Stock. @@ROWCOUNT straight after the UPDATE tells you whether a row was changed.

Show the solution
Solution.sql
UPDATE dbo.ProductsSET Stock = Stock - 5OUTPUT inserted.Name, deleted.Stock AS Before, inserted.Stock AS AfterWHERE ProductId = 8 AND Stock >= 5; UPDATE dbo.ProductsSET Stock = Stock - 5OUTPUT inserted.Name, deleted.Stock AS Before, inserted.Stock AS AfterWHERE ProductId = 9 AND Stock >= 5; IF @@ROWCOUNT = 0 PRINT 'Out of stock';
Output
Name               Before  After
-----------------  ------  -----
Wood screws (200)     150    145

Name  Before  After
----  ------  -----

Out of stock

Remember

  • Writers take X locks and keep them to the end of the transaction; readers take S locks.
  • A conflicting lock request waits: that is blocking. sys.dm_exec_requests shows blocking_session_id; sys.dm_tran_locks shows granted and waiting locks.
  • Dirty reads, non-repeatable reads and phantoms are the three problems isolation levels prevent.
  • READ UNCOMMITTED and NOLOCK read uncommitted data and can miss or double-read rows.
  • READ COMMITTED (the default) releases S locks after each row; REPEATABLE READ keeps them; SERIALIZABLE also locks key ranges.
  • Past about 5,000 locks on one table, one statement’s row locks escalate to a single table lock; change big tables in batches.
  • Read-then-write loses updates: do it in one UPDATE (Stock = Stock - 1 … WHERE Stock >= 1), or read WITH (UPDLOCK).
  • SET LOCK_TIMEOUT stops a session waiting for ever (Msg 1222).
0 of 54 lessons completed