Locking and Concurrency in SQL Server
LOCK MODES
S Shared : reading. Many S locks can exist together.
X Exclusive : writing. Nobody else can read (under locking) or write.
U Update : "I will update soon". Prevents a common deadlock.
IS / IX / IU : Intent locks on higher levels (page, table) saying
"somewhere below I have S / X / U locks".
Sch-S / Sch-M : Schema stability / modification (ALTER TABLE).
LOCK GRANULARITY : RID/KEY (row) -> PAGE -> TABLE (OBJECT)
LOCK ESCALATION : ~5000 row locks on one table -> converted into ONE table lock.
COMPATIBILITY (Y = allowed together)
S U X
S Y Y N
U Y N N
X N N N
CONCURRENCY MODELS
Pessimistic : lock first, then work (UPDLOCK, HOLDLOCK, SERIALIZABLE)
Optimistic : don't lock, detect conflicts on save (rowversion, SNAPSHOT)
Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.
USE SqlLearningDb;
GO
Topic tables
DROP TABLE IF EXISTS dbo.JobQueue;
DROP TABLE IF EXISTS dbo.Documents;
DROP TABLE IF EXISTS dbo.LockDemo;
-- For lock observation and escalation
CREATE TABLE dbo.LockDemo
(
ID INT IDENTITY(1,1) PRIMARY KEY,
Payload CHAR(100) NOT NULL DEFAULT 'x',
GroupNo INT NOT NULL
);
INSERT INTO dbo.LockDemo (GroupNo)
SELECT TOP (20000) ABS(CHECKSUM(NEWID())) % 10
FROM sys.all_objects a CROSS JOIN sys.all_objects b;
-- For optimistic concurrency (rowversion)
CREATE TABLE dbo.Documents
(
DocID INT PRIMARY KEY,
Title VARCHAR(100) NOT NULL,
Body VARCHAR(1000) NOT NULL,
UpdatedBy VARCHAR(50) NOT NULL,
RowVer ROWVERSION -- changes automatically on every update
);
INSERT INTO dbo.Documents (DocID, Title, Body, UpdatedBy) VALUES
(1, 'Leave Policy', 'Employees get 12 casual leaves.', 'Meena'),
(2, 'Travel Policy', 'Economy class for trips < 4 hours.', 'Suresh');
-- For a work queue processed by many workers at the same time
CREATE TABLE dbo.JobQueue
(
JobID INT IDENTITY(1,1) PRIMARY KEY,
JobType VARCHAR(20) NOT NULL,
Status VARCHAR(10) NOT NULL DEFAULT 'New', -- New / Running / Done
WorkerName VARCHAR(20) NULL,
CreatedAt DATETIME2(0) NOT NULL DEFAULT SYSDATETIME()
);
INSERT INTO dbo.JobQueue (JobType) VALUES
('SendEmail'), ('GenerateInvoice'), ('SendEmail'), ('ResizeImage'), ('SendSMS'),
('GenerateInvoice'), ('SendEmail'), ('ResizeImage'), ('SendSMS'), ('SendEmail');
GO
Messages(20000 rows affected)
(2 rows affected)
(10 rows affected)
Part A: See the Locks Your Own TRANSACTION Holds
-- Helper view of locks for the current session
CREATE OR ALTER VIEW dbo.vw_MyLocks
AS
SELECT l.request_session_id AS SessionID,
l.resource_type AS ResourceType,
l.request_mode AS Mode,
l.request_status AS Status,
l.resource_description AS Resource,
COALESCE(OBJECT_NAME(p.object_id),
CASE WHEN l.resource_type = 'OBJECT' THEN OBJECT_NAME(l.resource_associated_entity_id) END) AS ObjectName
FROM sys.dm_tran_locks l
LEFT JOIN sys.partitions p ON p.hobt_id = l.resource_associated_entity_id
WHERE l.resource_database_id = DB_ID();
GO
UPDATE one row -> X lock on the Key, Ix on Page and Object
BEGIN TRAN;
UPDATE dbo.LockDemo SET Payload = 'updated' WHERE ID = 10;
SELECT * FROM dbo.vw_MyLocks WHERE SessionID = @@SPID AND ObjectName = 'LockDemo';
ROLLBACK;
Output
| SessionID | ResourceType | Mode | Status | Resource | ObjectName |
| 54 | PAGE | IX | GRANT | 1:8768 ... | LockDemo |
| 54 | OBJECT | IX | GRANT | ... | LockDemo |
| 54 | KEY | X | GRANT | (d08358b1108f) ... | LockDemo |
SELECT under READ COMMITTED -> S locks are released immediately (nothing shown)
BEGIN TRAN;
SELECT * FROM dbo.LockDemo WHERE ID = 10;
SELECT 'READ COMMITTED' AS Level_, * FROM dbo.vw_MyLocks WHERE SessionID = @@SPID AND ObjectName = 'LockDemo';
ROLLBACK;
Output
| Level_ | SessionID | ResourceType | Mode | Status | Resource | ObjectName |
(0 rows)
SELECT with HOLDLOCK (= SERIALIZABLE) -> range S locks kept until end of transaction
BEGIN TRAN;
SELECT * FROM dbo.LockDemo WITH (HOLDLOCK) WHERE ID BETWEEN 10 AND 12;
SELECT 'HOLDLOCK' AS Level_, * FROM dbo.vw_MyLocks WHERE SessionID = @@SPID AND ObjectName = 'LockDemo';
ROLLBACK;
Output
| ID | Payload | GroupNo |
| 10 | x | 4 |
| 11 | x | 6 |
| 12 | x | 7 |
Output
| Level_ | SessionID | ResourceType | Mode | Status | Resource | ObjectName |
| HOLDLOCK | 54 | PAGE | IS | GRANT | 1:8768 ... | LockDemo |
| HOLDLOCK | 54 | OBJECT | IS | GRANT | ... | LockDemo |
| HOLDLOCK | 54 | KEY | RangeS-S | GRANT | (e8a66f387cfa) ... | LockDemo |
| HOLDLOCK | 54 | KEY | RangeS-S | GRANT | (d08358b1108f) ... | LockDemo |
| HOLDLOCK | 54 | KEY | RangeS-S | GRANT | (29cf3326f583) ... | LockDemo |
| HOLDLOCK | 54 | KEY | RangeS-S | GRANT | (11ea04af99f6) ... | LockDemo |
SELECT with UPDLOCK -> U locks (others can read, but cannot take U or X)
BEGIN TRAN;
SELECT * FROM dbo.LockDemo WITH (UPDLOCK) WHERE ID = 10;
SELECT 'UPDLOCK' AS Level_, * FROM dbo.vw_MyLocks WHERE SessionID = @@SPID AND ObjectName = 'LockDemo';
ROLLBACK;
GO
Output
| Level_ | SessionID | ResourceType | Mode | Status | Resource | ObjectName |
| UPDLOCK | 54 | PAGE | IU | GRANT | 1:8768 ... | LockDemo |
| UPDLOCK | 54 | OBJECT | IX | GRANT | ... | LockDemo |
| UPDLOCK | 54 | KEY | U | GRANT | (d08358b1108f) ... | LockDemo |
Part B: Lock Escalation
Update ~2000 rows: many Key locks
BEGIN TRAN;
UPDATE dbo.LockDemo SET Payload = 'g1' WHERE GroupNo = 1;
SELECT Mode, ResourceType, COUNT(*) AS LockCount
FROM dbo.vw_MyLocks WHERE SessionID = @@SPID AND ObjectName = 'LockDemo'
GROUP BY Mode, ResourceType;
ROLLBACK;
Messages(1965 rows affected)
Output
| Mode | ResourceType | LockCount |
| X | KEY | 1965 |
| IX | OBJECT | 1 |
| IX | PAGE | 327 |
Update All 20000 rows: escalates to One table X lock
BEGIN TRAN;
UPDATE dbo.LockDemo SET Payload = 'all';
SELECT Mode, ResourceType, COUNT(*) AS LockCount
FROM dbo.vw_MyLocks WHERE SessionID = @@SPID AND ObjectName = 'LockDemo'
GROUP BY Mode, ResourceType;
ROLLBACK;
Messages(20000 rows affected)
Output
| Mode | ResourceType | LockCount |
| X | OBJECT | 1 |
Control escalation per table: Table (default) | Auto (partition level) | Disable
ALTER TABLE dbo.LockDemo SET (LOCK_ESCALATION = TABLE);
SELECT name, lock_escalation_desc FROM sys.tables WHERE name = 'LockDemo';
-- Tip : update big tables in BATCHES to avoid escalation and long blocking
DECLARE @Rows INT = 1;
WHILE @Rows > 0
BEGIN
UPDATE TOP (2000) dbo.LockDemo SET Payload = 'batched' WHERE Payload <> 'batched';
SET @Rows = @@ROWCOUNT;
END;
SELECT Payload, COUNT(*) AS Cnt FROM dbo.LockDemo GROUP BY Payload;
GO
Output
| name | lock_escalation_desc |
| LockDemo | TABLE |
Messages(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(2000 rows affected)
(0 rows affected)
Part C: Table Hints (use carefully!)
- NOLOCK: read without S locks (dirty reads, may skip/duplicate rows)
- READPAST: skip rows that are locked by others
- UPDLOCK: take U locks when reading (reserve rows for update)
- HOLDLOCK: keep locks until the end of transaction (= SERIALIZABLE)
- ROWLOCK / PAGLOCK / TABLOCK / TABLOCKX: choose granularity
Job queue with many workers: each worker grabs the next Free job.
READPAST skips jobs locked by other workers, UPDLOCK reserves ours.
(Open 2 windows and run this block at the same time to see them get DIFFERENT jobs.)
DECLARE @Worker VARCHAR(20) = CONCAT('Worker-', @@SPID);
DECLARE @Taken TABLE (JobID INT, JobType VARCHAR(20));
BEGIN TRAN;
WITH NextJob AS
(
SELECT TOP (1) JobID, JobType, Status, WorkerName
FROM dbo.JobQueue WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE Status = 'New'
ORDER BY JobID
)
UPDATE NextJob
SET Status = 'Running', WorkerName = @Worker
OUTPUT inserted.JobID, inserted.JobType INTO @Taken;
-- WAITFOR DELAY '00:00:10'; -- uncomment to keep the lock while you test the second window
COMMIT;
SELECT 'I took job' AS Info, * FROM @Taken;
SELECT * FROM dbo.JobQueue;
GO
Output
| Info | JobID | JobType |
| I took job | 1 | SendEmail |
Output
| JobID | JobType | Status | WorkerName | CreatedAt |
| 1 | SendEmail | Running | Worker-54 | 2026-10-18 18:23:11 |
| 2 | GenerateInvoice | New | NULL | 2026-10-18 18:23:11 |
| 3 | SendEmail | New | NULL | 2026-10-18 18:23:11 |
| 4 | ResizeImage | New | NULL | 2026-10-18 18:23:11 |
| 5 | SendSMS | New | NULL | 2026-10-18 18:23:11 |
| 6 | GenerateInvoice | New | NULL | 2026-10-18 18:23:11 |
| 7 | SendEmail | New | NULL | 2026-10-18 18:23:11 |
| 8 | ResizeImage | New | NULL | 2026-10-18 18:23:11 |
| 9 | SendSMS | New | NULL | 2026-10-18 18:23:11 |
| 10 | SendEmail | New | NULL | 2026-10-18 18:23:11 |
TABLOCK for fast bulk insert into a staging table (minimal logging possible)
DROP TABLE IF EXISTS dbo.StagingBulk;
CREATE TABLE dbo.StagingBulk (ID INT, Payload CHAR(100));
INSERT INTO dbo.StagingBulk WITH (TABLOCK) (ID, Payload)
SELECT ID, Payload FROM dbo.LockDemo;
SELECT COUNT(*) AS LoadedRows FROM dbo.StagingBulk;
DROP TABLE dbo.StagingBulk;
GO
Messages(20000 rows affected)
Part D: Lost UPDATE Problem and how to prevent it
Two users read Qty = 10, both sell 1, both write 9 -> one sale is "lost".
BAD (read, then write with an old value):
DECLARE @q INT = (SELECT Qty FROM dbo.WarehouseStock WHERE ItemID = 1);
UPDATE dbo.WarehouseStock SET Qty = @q - 1 WHERE ItemID = 1;
GOOD 1 : do it in one atomic statement
UPDATE dbo.WarehouseStock SET Qty = Qty - 1 WHERE ItemID = 1 AND Qty >= 1;
GOOD 2 : pessimistic lock while reading
BEGIN TRAN;
SELECT Qty FROM dbo.WarehouseStock WITH (UPDLOCK) WHERE ItemID = 1;
UPDATE ...;
COMMIT;
GOOD 3 : optimistic concurrency with ROWVERSION (below)
Optimistic concurrency: the app remembers RowVer when it Reads the row,
and the UPDATE only succeeds if nobody changed the row in between.
-- User A and User B both open document 1 and remember its version
DECLARE @VersionSeenByA BINARY(8) = (SELECT RowVer FROM dbo.Documents WHERE DocID = 1);
DECLARE @VersionSeenByB BINARY(8) = @VersionSeenByA;
-- User A saves first -> succeeds, RowVer changes
UPDATE dbo.Documents
SET Body = 'Employees get 15 casual leaves.', UpdatedBy = 'UserA'
WHERE DocID = 1 AND RowVer = @VersionSeenByA;
SELECT 'User A save' AS Action_, @@ROWCOUNT AS RowsUpdated;
-- User B saves later with the OLD version -> 0 rows -> conflict detected
UPDATE dbo.Documents
SET Body = 'Employees get 10 casual leaves.', UpdatedBy = 'UserB'
WHERE DocID = 1 AND RowVer = @VersionSeenByB;
IF @@ROWCOUNT = 0
SELECT 'User B save' AS Action_, 'CONFLICT: document was changed by someone else. Reload and try again.' AS Message;
SELECT DocID, Body, UpdatedBy, RowVer FROM dbo.Documents;
GO
Output
| Action_ | RowsUpdated |
| User A save | 1 |
Messages(0 rows affected)
Output
| Action_ | Message |
| User B save | CONFLICT: document was changed by someone else. Reload and try again. |
Output
| DocID | Body | UpdatedBy | RowVer |
| 1 | Employees get 15 casual leaves. | UserA | 0x000000000000A425 |
| 2 | Economy class for trips < 4 hours. | Suresh | 0x000000000000A424 |
Part E: Application Locks (lock a "name", not a row)
Useful to make sure only ONE copy of a job runs at a time.
BEGIN TRAN;
DECLARE @r INT;
EXEC @r = sys.sp_getapplock @Resource = 'NightlyInvoiceJob', @LockMode = 'Exclusive',
@LockOwner = 'Transaction', @LockTimeout = 0;
IF @r >= 0
PRINT 'Got the lock - running nightly job...'; -- a second window would get -1 (timeout)
ELSE
PRINT 'Another session is already running the job.';
SELECT resource_type, resource_description, request_mode
FROM sys.dm_tran_locks WHERE request_session_id = @@SPID AND resource_type = 'APPLICATION';
COMMIT; -- releases the app lock
GO
MessagesGot the lock - running nightly job...
Output
| resource_type | resource_description | request_mode |
| APPLICATION | 0:[NightlyInvoiceJob]:(1003c7c8) ... | X |
Part F: Useful Monitoring Queries
Top waits on the server (LCK_M_* waits = lock blocking)
SELECT TOP (10) wait_type, waiting_tasks_count, wait_time_ms / 1000.0 AS WaitSeconds
FROM sys.dm_os_wait_stats
WHERE wait_type LIKE 'LCK%'
ORDER BY wait_time_ms DESC;
Output
| wait_type | waiting_tasks_count | WaitSeconds |
| LCK_M_S | 3 | 0.203000 |
| LCK_M_SCH_M | 14 | 0.003000 |
| LCK_M_SCH_S | 0 | 0.000000 |
| LCK_M_UIX | 0 | 0.000000 |
| LCK_M_SIX | 0 | 0.000000 |
| LCK_M_SIU | 0 | 0.000000 |
| LCK_M_IX | 0 | 0.000000 |
| LCK_M_IU | 0 | 0.000000 |
| LCK_M_IS | 0 | 0.000000 |
| LCK_M_X | 1 | 0.000000 |
Lock and latch statistics per index (which index has most row lock waits?)
SELECT OBJECT_NAME(s.object_id) AS TableName, i.name AS IndexName,
s.row_lock_count, s.row_lock_wait_count, s.page_lock_count, s.index_lock_promotion_count
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) s
JOIN sys.indexes i ON i.object_id = s.object_id AND i.index_id = s.index_id
WHERE OBJECTPROPERTY(s.object_id, 'IsUserTable') = 1
ORDER BY s.row_lock_count DESC;
GO
Output
| TableName | IndexName | row_lock_count | row_lock_wait_count | page_lock_count | index_lock_promotion_count |
| LockDemo | PK__LockDemo__3214EC27D9C560D0 | 184391 | 0 | 4705 | 1 |
| Sales | PK__Sales__1EE3C41FD7519D3C | 40418 | 0 | 670 | 0 |
| Employees | PK__Employee__7AD04FF16BB5104C | 8315 | 0 | 1024 | 0 |
| OrderItems | PK__OrderIte__57ED06A16C56CE5C | 8145 | 0 | 1306 | 0 |
| Orders | PK__Orders__C3905BAFC3FF0612 | 6505 | 0 | 1041 | 0 |
| Orders | IX_Orders_CustomerID | 2781 | 0 | 279 | 0 |
| Products | PK__Products__B40CC6ED91942313 | 2068 | 0 | 1149 | 0 |
| Departments | PK__Departme__B2079BCD273BD9FA | 999 | 0 | 205 | 0 |
| OrderItems | IX_OrderItems_OrderID | 803 | 0 | 252 | 0 |
| Customers | PK__Customer__A4AE64B868A196F9 | 748 | 0 | 324 | 0 |
| ProductPriceHistory | PK__ProductP__4D7B4ADDC20E996C | 640 | 0 | 54 | 0 |
| Students | PK__Students__32C52A7944DF4DB7 | 525 | 0 | 119 | 0 |
| Categories | PK__Categori__19093A2B750B7E1C | 432 | 0 | 151 | 0 |
| StockPrices | PK__StockPri__7B7AD5B030D2EA95 | 176 | 0 | 30 | 0 |
| CustomerSummary | PK__Customer__A4AE64B8222A6430 | 143 | 0 | 27 | 0 |
... showing 15 of 64 rows
Practice Questions
- Run A1 with PAGLOCK and TABLOCKX hints and compare the locks you see.
- Open two windows, run C1 in both with the WAITFOR uncommented - confirm they take different jobs.
- Remove READPAST from C1 and repeat. What happens to the second window?
- Simulate a lost update with two windows using the BAD pattern, then fix it with UPDLOCK.
- Add a RowVer column to dbo.Products and write a "safe price update" procedure using it.