Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
SQL

Writing SQL to Detect and Resolve Lock Contention: Using Query Plans, Wait Statistics, and Transaction Isolation to Diagnose and Fix Blocking in High-Concurrency Systems

Lock contention is one of the most damaging and misunderstood performance problems in production databases. This expert-level lesson walks you through the complete diagnostic and resolution workflow — from tracing blocking chains with recursive CTEs and system views, to reading execution plans for lock behavior, to choosing the right isolation level and implementing fixes that actually stick.

🔥 Expert31 min readNov 7, 2026Updated Nov 7, 2026
Writing SQL to Detect and Resolve Lock Contention: Using Query Plans, Wait Statistics, and Transaction Isolation to Diagnose and Fix Blocking in High-Concurrency Systems
On this page
  • Introduction
  • Prerequisites
  • Understanding What Locking Actually Is (And Why It Goes Wrong)
  • Diagnosing Blocking in SQL Server: The System View Stack
  • Finding Active Blockers Right Now
  • Building a Blocking Chain Tracer with CTEs
  • Reading Lock Details with sys.dm_tran_locks
  • Wait Statistics: The Long View
  • sys.dm_os_wait_stats
  • Diagnosing Blocking in PostgreSQL
  • The pg_locks and pg_stat_activity Join
  • PostgreSQL Wait Event Statistics with pg_stat_activity
  • Reading Query Plans to Understand Lock Acquisition
  • The Index Scan vs. Index Seek Difference Is Critical
  • The Lock Escalation Signal in Plans
  • Transaction Isolation Levels: The Architectural Lever
  • The Standard Isolation Levels and Their Lock Behavior
  • READ COMMITTED SNAPSHOT ISOLATION (RCSI) in SQL Server
  • PostgreSQL's MVCC Architecture
  • Practical Resolution Strategies
  • Strategy 1: Reduce Transaction Duration
  • Strategy 2: Fix the Index to Avoid Broad Lock Acquisition
  • Strategy 3: Rewrite to Reduce Lock Scope
  • Strategy 4: Disable Lock Escalation on Hot Tables
  • Strategy 5: Restructure for Deadlock Prevention with Consistent Lock Ordering
  • Strategy 6: Query Hints as a Last Resort
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Blaming the Wrong Session
  • Mistake 2: Using NOLOCK as a Performance Optimization
  • Mistake 3: Fixing the Symptom (Killing the Blocker) Instead of the Cause
  • Mistake 4: Using Serializable Isolation Unnecessarily
  • Mistake 5: Disabling Lock Escalation Globally
  • Troubleshooting: "I see no blockers but queries are still slow"
  • Summary & Next Steps
  • Writing SQL to Detect and Resolve Lock Contention: Using Query Plans, Wait Statistics, and Transaction Isolation to Diagnose and Fix Blocking in High-Concurrency Systems

    Introduction

    It's 2:47 PM on a Tuesday. Your e-commerce platform is running a flash sale. Order volume is 10x normal. Your on-call alert fires: average query latency has jumped from 40ms to 12 seconds. You log into the database server and see hundreds of sessions queued up, most of them doing absolutely nothing — just waiting. The queries themselves aren't slow. They're blocked.

    Lock contention is one of the most common and least understood performance problems in production database systems. Unlike a bad query plan that you can spot in development, lock contention is emergent — it appears only when multiple sessions compete for the same resources under real-world concurrency. It's also deceptive. The session you see waiting often isn't the problem; it's the victim. The real culprit is some other transaction holding a lock it hasn't released yet, possibly because of application logic you wrote months ago.

    By the end of this lesson, you will know how to identify blocking chains using system catalog views, interpret wait statistics to find the specific resources causing contention, read query execution plans to understand why locks are being acquired, choose and apply the right transaction isolation level for your workload, and implement concrete SQL-level fixes — ranging from query rewrites to isolation level changes to architectural adjustments — that actually eliminate the problem rather than paper over it.

    What you'll learn:

    • How SQL Server and PostgreSQL expose lock and wait information through system views, and how to query them systematically
    • How to trace a blocking chain from symptom (waiting session) back to root cause (the head blocker)
    • How execution plans reveal the access patterns that drive lock acquisition
    • How transaction isolation levels interact with locking — and how to pick the right one for your workload
    • Practical resolution strategies: query rewrites, index changes, isolation level adjustments, and application-level fixes

    Prerequisites

    This lesson assumes you're comfortable writing multi-table joins, using CTEs for query organization, and reading basic execution plans. You should also have a working understanding of transactions and ACID properties. If you need a refresher on isolation levels and how the database engine manages concurrent access, work through SQL Transactions, Isolation Levels, and Locking: A Complete Guide to Concurrent Database Programming before continuing here — this lesson picks up where that one leaves off and goes significantly deeper.


    Understanding What Locking Actually Is (And Why It Goes Wrong)

    Before we write a single diagnostic query, we need to be precise about what locks are and when they become a problem. This isn't just theoretical — if you don't understand the mechanics, you'll misinterpret your diagnostic data and apply the wrong fix.

    The database engine uses locks to enforce the I in ACID: isolation. When session A modifies a row, the engine acquires an exclusive lock (X lock) on that row. When session B tries to read or modify the same row, it must first acquire its own lock. Whether B can proceed depends on the lock compatibility matrix — shared locks (S) are compatible with other shared locks, but not with exclusive locks. The result: B waits.

    This is working as designed. The problem isn't that locking happens. The problem is that locks are held for too long, or acquired in too broad a scope, or acquired in conflicting orders across sessions.

    There are three distinct failure modes in high-concurrency systems:

    Blocking is the simple case: session B is waiting for a lock that session A holds. It will eventually proceed once A commits or rolls back. This is temporary but can cascade catastrophically when hundreds of sessions queue behind a single long-running transaction.

    Deadlock is the circular case: session A holds a lock that B wants, while B holds a lock that A wants. Neither can proceed. The engine detects this cycle and kills one session (the deadlock victim) to let the other continue.

    Lock escalation is the hidden multiplier: the engine promotes many fine-grained row locks into a single coarse table lock to conserve memory. This turns what would be isolated row-level contention into a situation where any writer on the table blocks all readers and writers.

    Key insight

    Most "slow query" complaints in OLTP systems under load are actually blocked queries. The query isn't slow — it's waiting. This distinction matters because the fix for a slow query (index, rewrite, stats) is completely different from the fix for a blocked query (transaction scope, isolation level, lock ordering).


    Diagnosing Blocking in SQL Server: The System View Stack

    SQL Server exposes a rich set of dynamic management views (DMVs) for diagnosing concurrency problems. Let's build a complete diagnostic toolkit from first principles.

    Finding Active Blockers Right Now

    The entry point is sys.dm_exec_requests, which shows currently executing requests and — critically — includes a blocking_session_id column. When this is nonzero, the request is waiting on another session.

    SELECT
        r.session_id,
        r.blocking_session_id,
        r.wait_type,
        r.wait_time / 1000.0 AS wait_seconds,
        r.status,
        r.command,
        DB_NAME(r.database_id) AS database_name,
        SUBSTRING(
            st.text,
            (r.statement_start_offset / 2) + 1,
            ((CASE r.statement_end_offset
                WHEN -1 THEN DATALENGTH(st.text)
                ELSE r.statement_end_offset
            END - r.statement_start_offset) / 2) + 1
        ) AS current_statement,
        r.cpu_time,
        r.logical_reads,
        r.row_count
    FROM sys.dm_exec_requests r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) st
    WHERE r.session_id != @@SPID  -- exclude this diagnostic query itself
    ORDER BY r.blocking_session_id DESC, r.wait_time DESC;
    

    The CROSS APPLY sys.dm_exec_sql_text call retrieves the actual SQL batch. The statement offset math extracts just the specific statement within that batch that's currently executing — important when stored procedures contain many statements.

    But this query only shows the blocked sessions. The blocker might not appear here if it's idle (transaction open but not currently running a query). To find the head blocker, you need to trace the chain.

    Building a Blocking Chain Tracer with CTEs

    A blocking chain can be multiple levels deep: session 55 is blocked by 42, which is blocked by 31. Session 31 is the head blocker — the one you actually need to investigate. Let's use a recursive CTE to traverse this chain:

    WITH BlockingChain AS (
        -- Anchor: find all head blockers (sessions that are blocking others
        -- but are not themselves blocked)
        SELECT
            s.session_id,
            s.login_name,
            s.program_name,
            s.host_name,
            CAST(NULL AS INT) AS blocked_by,
            0 AS chain_depth,
            CAST(s.session_id AS VARCHAR(1000)) AS chain_path
        FROM sys.dm_exec_sessions s
        WHERE s.session_id IN (
            SELECT DISTINCT blocking_session_id
            FROM sys.dm_exec_requests
            WHERE blocking_session_id != 0
        )
        AND s.session_id NOT IN (
            SELECT session_id
            FROM sys.dm_exec_requests
            WHERE blocking_session_id != 0
        )
    
        UNION ALL
    
        -- Recursive: find each session blocked by something in the chain so far
        SELECT
            r.session_id,
            s.login_name,
            s.program_name,
            s.host_name,
            r.blocking_session_id AS blocked_by,
            bc.chain_depth + 1,
            CAST(bc.chain_path + ' -> ' + CAST(r.session_id AS VARCHAR(10)) AS VARCHAR(1000))
        FROM sys.dm_exec_requests r
        JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
        JOIN BlockingChain bc ON bc.session_id = r.blocking_session_id
        WHERE r.blocking_session_id != 0
    )
    SELECT
        bc.chain_path,
        bc.chain_depth,
        bc.session_id,
        bc.blocked_by,
        bc.login_name,
        bc.program_name,
        bc.host_name,
        r.wait_type,
        r.wait_time / 1000.0 AS wait_seconds,
        r.status,
        SUBSTRING(
            st.text,
            (r.statement_start_offset / 2) + 1,
            ((CASE r.statement_end_offset
                WHEN -1 THEN DATALENGTH(st.text)
                ELSE r.statement_end_offset
            END - r.statement_start_offset) / 2) + 1
        ) AS current_statement,
        t.open_transaction_count,
        s2.last_request_start_time,
        s2.last_request_end_time
    FROM BlockingChain bc
    LEFT JOIN sys.dm_exec_requests r ON r.session_id = bc.session_id
    LEFT JOIN sys.dm_exec_sessions s2 ON s2.session_id = bc.session_id
    LEFT JOIN sys.dm_tran_session_transactions tst ON tst.session_id = bc.session_id
    LEFT JOIN sys.dm_tran_active_transactions t ON t.transaction_id = tst.transaction_id
    OUTER APPLY (
        SELECT text
        FROM sys.dm_exec_sql_text(r.sql_handle)
    ) st
    ORDER BY bc.chain_depth, bc.wait_time DESC;
    

    This query produces a chain_path like 31 -> 42 -> 55 -> 68, showing you exactly who's blocking whom. The open_transaction_count on the head blocker is diagnostic gold: if it's 1 or higher and last_request_end_time is well in the past, you have an idle open transaction — the most common and most fixable cause of blocking.

    Warning

    An idle open transaction (a session that opened a transaction, ran some DML, and then stopped without committing or rolling back) will hold its locks indefinitely. This often happens in application code that opens a transaction before making an API call, and the API call hangs or errors without the exception handler rolling back. The database can't tell the difference between "this transaction is thinking" and "this application is broken."

    Reading Lock Details with sys.dm_tran_locks

    Once you know which sessions are involved, you want to know what they're locking. sys.dm_tran_locks shows every lock currently held or requested in the system:

    SELECT
        tl.resource_type,
        tl.resource_database_id,
        DB_NAME(tl.resource_database_id) AS database_name,
        tl.resource_description,
        -- For object locks, resolve the object name
        CASE tl.resource_type
            WHEN 'OBJECT' THEN OBJECT_NAME(tl.resource_associated_entity_id, tl.resource_database_id)
            ELSE NULL
        END AS object_name,
        -- For page/row locks, resolve through partition to object
        CASE tl.resource_type
            WHEN 'PAGE' THEN
                (SELECT OBJECT_NAME(object_id, tl.resource_database_id)
                 FROM sys.partitions
                 WHERE partition_id = tl.resource_associated_entity_id)
            WHEN 'RID' THEN
                (SELECT OBJECT_NAME(object_id, tl.resource_database_id)
                 FROM sys.partitions
                 WHERE partition_id = tl.resource_associated_entity_id)
            WHEN 'KEY' THEN
                (SELECT OBJECT_NAME(object_id, tl.resource_database_id)
                 FROM sys.partitions
                 WHERE partition_id = tl.resource_associated_entity_id)
            ELSE NULL
        END AS locked_table,
        tl.resource_lock_partition,
        tl.request_mode,       -- S, X, U, IS, IX, SIX
        tl.request_type,       -- LOCK (held) or WAIT
        tl.request_status,     -- GRANT, WAIT, CONVERT
        tl.request_session_id,
        tl.request_owner_type
    FROM sys.dm_tran_locks tl
    WHERE tl.resource_database_id = DB_ID()  -- current database only
      AND tl.request_session_id != @@SPID
    ORDER BY
        tl.resource_type,
        tl.resource_description,
        tl.request_status;
    

    The request_mode column tells you the type of lock: X (exclusive, held by writer), S (shared, held by reader), U (update lock — the engine takes this before converting to X to prevent deadlocks), IS/IX (intent locks at the page or table level, indicating that finer-grained locks exist below).

    Look for rows where the same resource_description has one row with request_status = GRANT and another with request_status = WAIT. That resource is your contention point. Cross-reference the session IDs against your blocking chain output.


    Wait Statistics: The Long View

    The DMV queries above show you the current state. But to understand chronic contention patterns — the kind that gets worse as load increases — you need aggregate wait statistics.

    sys.dm_os_wait_stats

    This view accumulates wait statistics since SQL Server last started (or since they were last cleared). The lock-related wait types to focus on:

    Wait Type Meaning
    LCK_M_X Waiting for an exclusive lock
    LCK_M_S Waiting for a shared lock
    LCK_M_U Waiting for an update lock
    LCK_M_SCH_M Waiting for a schema modification lock (DDL blocking DML)
    LCK_M_RS_U Waiting for a range-scan update lock (snapshot isolation related)
    PAGEIOLATCH_* Waiting for a data page to be read from disk (not a lock, but often confused with one)
    SELECT
        wait_type,
        waiting_tasks_count,
        wait_time_ms,
        max_wait_time_ms,
        signal_wait_time_ms,
        -- Time spent actually waiting for the resource (vs. waiting to get CPU after wakeup)
        wait_time_ms - signal_wait_time_ms AS resource_wait_time_ms,
        CAST(100.0 * wait_time_ms / SUM(wait_time_ms) OVER () AS DECIMAL(5,2)) AS pct_of_total
    FROM sys.dm_os_wait_stats
    WHERE wait_type LIKE 'LCK%'
      AND wait_type NOT IN (
          -- Filter out background/benign waits
          'LCK_M_SCH_S'  -- Schema stability lock - low impact
      )
      AND waiting_tasks_count > 0
    ORDER BY wait_time_ms DESC;
    

    The signal_wait_time_ms column is worth understanding: it's the time a task spent waiting to get scheduled on a CPU after its lock was granted. High signal wait time relative to total wait time suggests CPU pressure, not lock contention. Low signal wait time with high resource_wait_time_ms confirms genuine lock contention.

    Note

    sys.dm_os_wait_stats is cumulative and resets on restart or when you run DBCC SQLPERF('sys.dm_os_wait_stats', CLEAR). For trend analysis, you want to capture snapshots over time and diff them. Consider scheduling a job that inserts rows from this view into a monitoring table every 5 minutes — then you can see when lock waits spike without having to be watching in real time.


    Diagnosing Blocking in PostgreSQL

    PostgreSQL's approach to exposing concurrency problems is different but equally powerful. Let me show you the equivalent diagnostic stack.

    The pg_locks and pg_stat_activity Join

    The fundamental PostgreSQL diagnostic query joins pg_locks (which locks are held or awaited) with pg_stat_activity (what each backend is doing):

    SELECT
        blocked_locks.pid AS blocked_pid,
        blocked_activity.usename AS blocked_user,
        blocked_activity.application_name AS blocked_app,
        blocking_locks.pid AS blocking_pid,
        blocking_activity.usename AS blocking_user,
        blocking_activity.application_name AS blocking_app,
        blocked_activity.query AS blocked_query,
        blocking_activity.query AS blocking_query,
        blocked_activity.query_start AS blocked_query_start,
        blocking_activity.query_start AS blocking_query_start,
        now() - blocked_activity.query_start AS blocked_duration,
        now() - blocking_activity.query_start AS blocking_duration,
        blocked_locks.locktype,
        blocked_locks.relation::regclass AS locked_relation,
        blocked_locks.mode AS requested_mode,
        blocking_locks.mode AS held_mode
    FROM pg_locks blocked_locks
    JOIN pg_stat_activity blocked_activity
        ON blocked_activity.pid = blocked_locks.pid
    JOIN pg_locks blocking_locks
        ON blocking_locks.locktype = blocked_locks.locktype
        AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
        AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
        AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
        AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
        AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
        AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
        AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
        AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
        AND blocking_locks.pid != blocked_locks.pid
    JOIN pg_stat_activity blocking_activity
        ON blocking_activity.pid = blocking_locks.pid
    WHERE NOT blocked_locks.granted;
    

    The NOT blocked_locks.granted condition filters to only locks that are being requested but not yet acquired — these are your blocked sessions.

    PostgreSQL Wait Event Statistics with pg_stat_activity

    PostgreSQL 9.6+ exposes wait_event_type and wait_event in pg_stat_activity. For lock waits specifically:

    SELECT
        pid,
        usename,
        application_name,
        state,
        wait_event_type,
        wait_event,
        query_start,
        now() - query_start AS query_duration,
        LEFT(query, 200) AS query_snippet
    FROM pg_stat_activity
    WHERE wait_event_type = 'Lock'
      AND pid != pg_backend_pid()
    ORDER BY query_start ASC;
    

    When wait_event_type = 'Lock', the wait_event column tells you the specific lock type being waited on: relation, tuple, transactionid, etc. A transactionid wait means you're waiting for another transaction to commit or rollback — the most common case in OLTP systems.


    Reading Query Plans to Understand Lock Acquisition

    You can't fix lock contention without understanding why the queries acquire the locks they do. This is where execution plans become essential.

    For a deep dive into reading plans in general, see SQL Query Optimization: Reading Execution Plans - Advanced Performance Analysis. Here we focus specifically on what plans reveal about locking behavior.

    The Index Scan vs. Index Seek Difference Is Critical

    An index seek navigates directly to specific rows. An index scan reads pages sequentially. This distinction matters enormously for locking under READ COMMITTED or higher isolation levels because:

    • An index seek typically acquires and immediately releases row-level locks as it moves through the result set (under READ COMMITTED)
    • An index scan may hold locks on many more rows or pages simultaneously, depending on the access pattern
    • A table scan (clustered index scan or heap scan) under serializable isolation can escalate to a full table lock
    -- This query scans an index range and may hold significant locks:
    SELECT order_id, customer_id, total_amount
    FROM orders
    WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'
      AND status = 'PENDING';
    
    -- Get the actual plan with lock information
    SET STATISTICS IO ON;
    SET STATISTICS TIME ON;
    
    -- For SQL Server, capture the actual plan:
    SELECT order_id, customer_id, total_amount
    FROM orders WITH (NOLOCK)  -- We'll discuss this tradeoff later
    WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'
      AND status = 'PENDING';
    

    In the execution plan, look for:

    • Clustered Index Scan: The worst case for lock duration — reads all pages of a table
    • Index Scan on a non-covering index: May cause a lookup for each row (key lookup or RID lookup), multiplying lock acquisition
    • Sort operations: Sort operators spool their entire input before producing output, which means locks are held longer because the transaction isn't streaming results
    • Hash Join vs. Nested Loop: Hash joins build hash tables from one input before probing with the other — again, locks held longer on the build side

    Tip

    In SQL Server, right-click any operator in the graphical execution plan and look at "Actual Rows" vs. "Estimated Rows." A large discrepancy (say, estimated 100 rows, actual 1,200,000 rows) indicates stale statistics. The optimizer chose a plan expecting little data, got much more, and now it's holding many more locks than anticipated — a classic driver of unexpected contention.

    The Lock Escalation Signal in Plans

    SQL Server's lock escalation happens when a session acquires more than ~5,000 row or page locks on a single table. When this occurs, the engine converts those many fine-grained locks into a single table lock. You can see this happened by looking for LCK_M_TAB in your wait statistics, or by querying the escalation event in the extended events log.

    -- Check if lock escalation is happening on specific tables
    SELECT
        OBJECT_NAME(dle.object_id) AS table_name,
        dle.escalated_lock_count,
        dle.escalation_reason
    FROM sys.dm_exec_query_stats qs
    -- Lock escalation DMV (available in SQL Server 2016+)
    -- For older versions, use Extended Events
    

    For production diagnosis, Extended Events is more reliable:

    -- Create a session to capture lock escalation events
    CREATE EVENT SESSION [LockEscalation_Monitor] ON SERVER
    ADD EVENT sqlserver.lock_escalation(
        ACTION(
            sqlserver.sql_text,
            sqlserver.username,
            sqlserver.database_name,
            sqlserver.session_id
        )
        WHERE (
            [sqlserver].[database_name] = N'YourDatabase'
        )
    )
    ADD TARGET package0.ring_buffer(SET max_memory = 51200)
    WITH (MAX_DISPATCH_LATENCY = 5 SECONDS);
    
    ALTER EVENT SESSION [LockEscalation_Monitor] ON SERVER STATE = START;
    

    Then read from it:

    SELECT
        xdr.value('@name', 'nvarchar(100)') AS event_name,
        xdr.value('(data[@name="object_id"]/value)[1]', 'bigint') AS object_id,
        OBJECT_NAME(xdr.value('(data[@name="object_id"]/value)[1]', 'bigint')) AS table_name,
        xdr.value('(action[@name="sql_text"]/value)[1]', 'nvarchar(max)') AS sql_text,
        xdr.value('(action[@name="username"]/value)[1]', 'nvarchar(100)') AS username,
        xdr.value('@timestamp', 'datetime2') AS event_time
    FROM (
        SELECT CAST(target_data AS XML) AS target_data
        FROM sys.dm_xe_sessions s
        JOIN sys.dm_xe_session_targets t ON s.address = t.event_session_address
        WHERE s.name = 'LockEscalation_Monitor'
          AND t.target_name = 'ring_buffer'
    ) AS data
    CROSS APPLY target_data.nodes('RingBufferTarget/event') AS XEventData(xdr)
    ORDER BY xdr.value('@timestamp', 'datetime2') DESC;
    

    Transaction Isolation Levels: The Architectural Lever

    The isolation level your application uses is the single most powerful lever for controlling lock behavior. You need to understand the tradeoffs deeply, not just the names.

    The Standard Isolation Levels and Their Lock Behavior

    Under READ UNCOMMITTED: No shared locks acquired for reads. You can read uncommitted data (dirty reads). This is the most aggressive concurrency option — almost never appropriate for financial or inventory data.

    Under READ COMMITTED (the default in SQL Server and PostgreSQL): Shared locks are acquired and immediately released after each row is read. This prevents dirty reads but allows non-repeatable reads (the same row can change between two reads in the same transaction).

    Under REPEATABLE READ: Shared locks are held until end of transaction. This prevents non-repeatable reads but allows phantom reads (new rows inserted by other transactions can appear in range scans).

    Under SERIALIZABLE: Range locks (key range locks in SQL Server, predicate locks in PostgreSQL) are added to prevent phantom reads. This is the most restrictive and the most likely to cause contention.

    Key insight

    Moving from SERIALIZABLE to READ COMMITTED usually eliminates the majority of blocking in OLTP systems, because readers stop blocking writers. The tradeoff is that you must design your application logic to tolerate non-repeatable reads. This is almost always acceptable for read-heavy reporting queries, batch jobs reading for processing, and dashboards. It may not be acceptable for financial reconciliation or inventory deduction logic.

    READ COMMITTED SNAPSHOT ISOLATION (RCSI) in SQL Server

    SQL Server's most powerful concurrency feature is RCSI (Read Committed Snapshot Isolation). When enabled, reads use the row version store rather than acquiring shared locks at all. Writers still acquire exclusive locks, but readers never block writers and writers never block readers.

    -- Check if RCSI is enabled
    SELECT
        name,
        is_read_committed_snapshot_on,
        snapshot_isolation_state_desc
    FROM sys.databases
    WHERE name = DB_NAME();
    
    -- Enable RCSI (requires brief exclusive access to the database)
    ALTER DATABASE YourDatabase
    SET READ_COMMITTED_SNAPSHOT ON;
    

    Warning

    Enabling RCSI adds overhead to tempdb (or the version store in SQL Server 2019+ with accelerated database recovery). Every modified row is written to the version store so readers can see the previous version. Under heavy write workloads, tempdb can become the bottleneck. Monitor sys.dm_db_xtp_checkpoint_files and tempdb space usage after enabling.

    PostgreSQL's MVCC Architecture

    PostgreSQL uses MVCC (Multi-Version Concurrency Control) for all isolation levels, which means readers never block writers by default — unlike SQL Server's default behavior. The PostgreSQL equivalent of RCSI is effectively the default. However, PostgreSQL does have its own locking behaviors worth understanding:

    Row-level locks in PostgreSQL are acquired for UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE. These are stored in the row header itself (not in a separate lock manager table like SQL Server), which means PostgreSQL doesn't escalate row locks to table locks the same way. However, SELECT FOR UPDATE does acquire row-level advisory locks that can cause contention.

    -- PostgreSQL: Explicit locking for optimistic concurrency
    -- Pattern: "I want to process this order exclusively"
    BEGIN;
    
    SELECT order_id, status, total_amount
    FROM orders
    WHERE order_id = 10045
      AND status = 'PENDING'
    FOR UPDATE SKIP LOCKED;  -- Skip rows already locked by another session
    
    -- If we got a row, process it
    UPDATE orders
    SET status = 'PROCESSING', updated_at = NOW()
    WHERE order_id = 10045;
    
    COMMIT;
    

    SKIP LOCKED is a game-changer for queue-style workloads. Without it, every worker session would block each other trying to claim the same "next pending order." With SKIP LOCKED, each session instantly moves past locked rows and finds an available one, eliminating contention entirely.


    Practical Resolution Strategies

    Now we bring it together. You've identified a blocking problem. Here are the concrete techniques to resolve it, ordered roughly from least invasive to most invasive.

    Strategy 1: Reduce Transaction Duration

    The most common fix. If you find head blockers that are idle open transactions, the solution is in the application code, not the database. The pattern to look for:

    -- Anti-pattern: Long-running work inside a transaction
    BEGIN TRANSACTION;
        -- Step 1: Read data to determine what to do
        SELECT @inventory_count = quantity
        FROM inventory WHERE product_id = @product_id;
    
        -- Step 2: Call external API (THIS IS THE PROBLEM)
        -- The external call might take seconds or fail entirely
        -- Meanwhile, locks are held on inventory
    
        -- Step 3: Update based on API result
        UPDATE inventory
        SET quantity = quantity - @order_qty
        WHERE product_id = @product_id;
    COMMIT;
    
    -- Better pattern: Minimize what happens inside the transaction
    -- Step 1: Do all reads and external calls BEFORE opening transaction
    SELECT @inventory_count = quantity
    FROM inventory WHERE product_id = @product_id;
    
    -- Make external API call here, with no transaction open
    
    -- Step 2: Transaction only covers the atomic write
    BEGIN TRANSACTION;
        UPDATE inventory
        SET quantity = quantity - @order_qty
        WHERE product_id = @product_id
          AND quantity >= @order_qty;  -- Guard condition
    
        IF @@ROWCOUNT = 0
            ROLLBACK;  -- Insufficient inventory or concurrent update won
        ELSE
            COMMIT;
    

    Strategy 2: Fix the Index to Avoid Broad Lock Acquisition

    A missing index forces the engine to scan more rows, acquiring more locks than necessary. If your blocking query is a SELECT with a WHERE clause that isn't covered by an index, adding a covering index can eliminate the blocking entirely by allowing a precise seek instead of a scan.

    -- Scenario: This UPDATE is blocking many readers
    UPDATE orders
    SET status = 'SHIPPED', shipped_at = GETDATE()
    WHERE customer_id = 8471
      AND status = 'PROCESSING';
    
    -- Without an index on (customer_id, status), this scans the orders table
    -- With a large orders table, this holds locks on potentially millions of rows
    
    -- Check the missing index DMV in SQL Server
    SELECT
        mid.statement AS table_name,
        mid.equality_columns,
        mid.inequality_columns,
        mid.included_columns,
        migs.unique_compiles,
        migs.user_seeks,
        migs.user_scans,
        migs.avg_total_user_cost * migs.avg_user_impact * (migs.user_seeks + migs.user_scans)
            AS estimated_improvement_score
    FROM sys.dm_db_missing_index_details mid
    JOIN sys.dm_db_missing_index_groups mig
        ON mid.index_handle = mig.index_handle
    JOIN sys.dm_db_missing_index_group_stats migs
        ON mig.index_group_handle = migs.group_handle
    WHERE mid.database_id = DB_ID()
    ORDER BY estimated_improvement_score DESC;
    
    -- Add the targeted index
    CREATE INDEX IX_orders_customer_status
    ON orders (customer_id, status)
    INCLUDE (shipped_at);  -- Cover the columns being written so we avoid a lookup
    

    For more on choosing and creating effective indexes, see Indexing Fundamentals for Query Performance: How to Choose, Create, and Evaluate Indexes on Real Tables and Database Performance Tuning: Advanced Indexing Strategies and Query Rewriting for Production Systems.

    Strategy 3: Rewrite to Reduce Lock Scope

    Sometimes you can restructure a query to do less work inside a transaction. The batch delete pattern is a classic example:

    -- Anti-pattern: Delete 500,000 rows in one transaction
    -- This holds locks on all 500,000 rows simultaneously
    -- and may escalate to a table lock
    DELETE FROM audit_log
    WHERE created_at < DATEADD(DAY, -365, GETDATE());
    
    -- Better: Batch deletes with small transactions
    DECLARE @batch_size INT = 5000;
    DECLARE @rows_deleted INT = 1;
    
    WHILE @rows_deleted > 0
    BEGIN
        DELETE TOP (@batch_size) FROM audit_log
        WHERE created_at < DATEADD(DAY, -365, GETDATE());
    
        SET @rows_deleted = @@ROWCOUNT;
    
        -- Small delay to let other sessions get in
        -- This is optional but courteous in OLTP environments
        IF @rows_deleted = @batch_size
            WAITFOR DELAY '00:00:00.100';
    END;
    

    Each iteration holds locks only on 5,000 rows, commits them, and releases. Other sessions get windows to execute between batches. The total work is the same, but the peak lock footprint is a small fraction of the original.

    Strategy 4: Disable Lock Escalation on Hot Tables

    For tables that see heavy concurrent access, you can prevent the engine from escalating to table locks:

    -- SQL Server: Disable lock escalation on a specific table
    -- This keeps locks at the row or page level even when thresholds are exceeded
    -- Only do this on tables with good row-level indexes; otherwise memory pressure from
    -- many small locks can cause other problems
    ALTER TABLE orders SET (LOCK_ESCALATION = DISABLE);
    
    -- Or use partition-level escalation for partitioned tables
    -- (escalates to partition lock rather than table lock)
    ALTER TABLE orders SET (LOCK_ESCALATION = AUTO);
    

    Warning

    Disabling lock escalation increases the memory footprint of the lock manager. Each lock is a 64-byte structure. If you disable escalation on a table and a query starts acquiring millions of row locks, the lock manager's memory allocation can grow dramatically. Use this only on tables where you've confirmed lock escalation is causing contention, and monitor sys.dm_os_memory_clerks where type = 'MEMORYCLERK_SQLGENERAL' afterward.

    Strategy 5: Restructure for Deadlock Prevention with Consistent Lock Ordering

    Deadlocks are almost always caused by two transactions acquiring the same locks in opposite orders. The fix is to enforce a consistent acquisition order across all transactions that touch the same resources.

    -- Anti-pattern: Two stored procedures that deadlock
    -- Proc A: Updates account 1, then account 2
    -- Proc B: Updates account 2, then account 1
    
    -- Proc A
    BEGIN TRANSACTION;
        UPDATE accounts SET balance = balance - 500 WHERE account_id = 1001;
        -- <-- If Proc B runs here, it locks account 2 and then tries for account 1 (DEADLOCK)
        UPDATE accounts SET balance = balance + 500 WHERE account_id = 2001;
    COMMIT;
    
    -- Fix: Always acquire locks in the same order (lower account_id first)
    -- Proc A (transfer FROM 1001 TO 2001):
    BEGIN TRANSACTION;
        -- Lock the lower account_id first, always
        UPDATE accounts SET balance = balance - 500 WHERE account_id = 1001;  -- 1001 < 2001, so this first
        UPDATE accounts SET balance = balance + 500 WHERE account_id = 2001;
    COMMIT;
    
    -- Proc B (transfer FROM 2001 TO 1001):
    BEGIN TRANSACTION;
        -- Still lock lower account_id first (even though we're debiting from the higher one)
        UPDATE accounts SET balance = balance + 500 WHERE account_id = 1001;  -- 1001 < 2001, still first
        UPDATE accounts SET balance = balance - 500 WHERE account_id = 2001;
    COMMIT;
    

    This consistent ordering prevents the circular dependency that causes deadlocks. It's a discipline that must be enforced across all code paths that touch these resources — which is a compelling reason to use stored procedures and user-defined functions to encapsulate this logic rather than spreading it across application code.

    Strategy 6: Query Hints as a Last Resort

    SQL Server offers several lock-related query hints. Use these only when you understand exactly what you're trading away:

    -- NOLOCK: Read uncommitted data. Fast, but you may read in-flight data
    -- that gets rolled back (phantom data). Never use for financial calculations.
    SELECT product_id, SUM(quantity) AS total_ordered
    FROM order_items WITH (NOLOCK)
    WHERE order_date >= '2024-01-01'
    GROUP BY product_id;
    
    -- UPDLOCK: Acquire update locks during read, converting to X lock on write
    -- Use when you read a row intending to update it, to prevent another session
    -- from modifying it between your read and write
    SELECT quantity
    FROM inventory WITH (UPDLOCK)
    WHERE product_id = @product_id;
    
    -- ROWLOCK: Force row-level locking (prevent page or table lock escalation for this query)
    UPDATE order_items WITH (ROWLOCK)
    SET status = 'SHIPPED'
    WHERE order_id = @order_id;
    
    -- PAGLOCK: Force page-level locking (sometimes more efficient than row for bulk ops)
    -- Rarely needed in modern SQL Server versions
    

    Note

    WITH (NOLOCK) is not equivalent to "read-only" or "safe for reporting." It can return rows that were never committed, miss rows that exist, and in some edge cases return the same row twice or miss pages during a scan due to page splits. It's appropriate for large aggregate reports where approximate results are acceptable and freshness matters more than precision. It's never appropriate for order processing, inventory management, or anything that drives a business decision.


    Hands-On Exercise

    You'll need a SQL Server instance (or Azure SQL Database) with the AdventureWorks sample database, or you can create the scenario using the scripts below.

    Scenario: A high-traffic retail system is experiencing blocking during the end-of-day order processing. Orders are being read and updated by multiple concurrent sessions, causing significant latency.

    Setup:

    -- Create a minimal test scenario
    CREATE TABLE dbo.CustomerOrders (
        order_id INT IDENTITY(1,1) PRIMARY KEY CLUSTERED,
        customer_id INT NOT NULL,
        product_id INT NOT NULL,
        quantity INT NOT NULL,
        unit_price DECIMAL(10,2) NOT NULL,
        status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
        created_at DATETIME2 NOT NULL DEFAULT GETDATE(),
        processed_at DATETIME2 NULL
    );
    
    CREATE TABLE dbo.ProductInventory (
        product_id INT PRIMARY KEY,
        product_name VARCHAR(100) NOT NULL,
        quantity_on_hand INT NOT NULL,
        reserved_quantity INT NOT NULL DEFAULT 0,
        last_updated DATETIME2 NOT NULL DEFAULT GETDATE()
    );
    
    -- Populate with realistic data volumes
    INSERT INTO dbo.ProductInventory (product_id, product_name, quantity_on_hand)
    SELECT
        n,
        'Product_' + CAST(n AS VARCHAR(10)),
        1000 + (n * 7 % 500)
    FROM (
        SELECT TOP 1000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
        FROM sys.objects a CROSS JOIN sys.objects b
    ) nums;
    
    INSERT INTO dbo.CustomerOrders (customer_id, product_id, quantity, unit_price, status)
    SELECT
        1 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 5000),
        1 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 1000),
        1 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 10),
        9.99 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100),
        CASE ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 5
            WHEN 0 THEN 'PENDING'
            WHEN 1 THEN 'PROCESSING'
            WHEN 2 THEN 'SHIPPED'
            WHEN 3 THEN 'DELIVERED'
            ELSE 'PENDING'
        END
    FROM (
        SELECT TOP 100000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
        FROM sys.objects a CROSS JOIN sys.objects b CROSS JOIN sys.objects c
    ) nums;
    

    Exercise Tasks:

    Task 1: Open Session 1 and begin (but don't commit) an update:

    -- Session 1: Simulate a slow order processing transaction
    BEGIN TRANSACTION;
    
    UPDATE dbo.ProductInventory
    SET reserved_quantity = reserved_quantity + 5,
        last_updated = GETDATE()
    WHERE product_id BETWEEN 100 AND 200;
    
    -- DO NOT COMMIT YET. Keep this session open.
    -- Simulate the application doing external work (sleeping)
    WAITFOR DELAY '00:00:30';
    
    COMMIT;
    

    Task 2: In Session 2, run your blocking chain diagnostic query and observe:

    • Which session is the head blocker
    • What resources are locked
    • What the lock types are

    Task 3: In Session 3, try to run this query and observe it blocking:

    SELECT product_id, product_name, quantity_on_hand, reserved_quantity
    FROM dbo.ProductInventory
    WHERE product_id BETWEEN 150 AND 250;  -- Overlaps with the locked range
    

    Task 4: Use sys.dm_tran_locks to identify the specific lock resources. Answer: What is the lock type (resource_type) you see, and what mode does Session 2 show for the conflicting resource?

    Task 5: Now implement the fix. Modify the Session 1 transaction to use row-level targeted updates instead of a range update, and add an appropriate index. Verify that Session 3 no longer blocks.

    Expected solution involves:

    1. Adding CREATE INDEX IX_ProductInventory_product_id ON dbo.ProductInventory (product_id) (which is the PK anyway — in this case, the real fix is using WHERE product_id IN (SELECT specific_ids) to avoid escalation, or batching the update)
    2. Restructuring the transaction to minimize duration by pre-computing the list of products to update and processing them in targeted batches

    Common Mistakes & Troubleshooting

    Mistake 1: Blaming the Wrong Session

    The session you see waiting is almost never the problem. When you see blocking_session_id = 55, your instinct is to look at session 55. But session 55 might itself be blocked by session 31. Session 31 is the one to investigate. Always trace to the head blocker before forming any hypothesis.

    Mistake 2: Using NOLOCK as a Performance Optimization

    WITH (NOLOCK) does not make queries faster by itself in any meaningful way on modern SQL Server with RCSI enabled. It was a 2000s-era workaround for systems that hadn't enabled RCSI. If you're applying NOLOCK to avoid blocking, the right answer is almost always to enable RCSI instead — you get the same concurrency benefit without the dirty read risk.

    Mistake 3: Fixing the Symptom (Killing the Blocker) Instead of the Cause

    It's tempting to kill the head blocker session when users are complaining. KILL 31 will unblock everything immediately. But if the underlying cause (long transaction in application code, missing index causing lock escalation, deadlock-prone lock ordering) isn't fixed, the same blocking pattern will recur within minutes. Kill the session as a temporary relief measure while you deploy the actual fix.

    Mistake 4: Using Serializable Isolation Unnecessarily

    Some teams choose SERIALIZABLE because it sounds safest. It is the safest in terms of data consistency, but it's also the most aggressive in terms of locking. For the vast majority of OLTP applications, READ COMMITTED or READ COMMITTED SNAPSHOT provides sufficient consistency guarantees while dramatically reducing contention. Only use SERIALIZABLE when you have a specific, identified consistency requirement that lesser isolation levels genuinely cannot satisfy.

    Mistake 5: Disabling Lock Escalation Globally

    We covered disabling lock escalation per-table. Some DBAs, faced with pervasive escalation problems, are tempted to disable it at the server level via trace flag 1211 or 1224. Don't. This can cause the lock manager's memory to grow unboundedly under heavy load, potentially causing out-of-memory conditions that are far worse than the original blocking.

    Troubleshooting: "I see no blockers but queries are still slow"

    This usually means one of:

    1. PAGEIOLATCH waits — pages are being read from disk, not blocked by locks. This is an I/O or memory (buffer pool) problem, not a locking problem. Check sys.dm_os_wait_stats for PAGEIOLATCH_SH.
    2. CXPACKET or CXCONSUMER waits — parallelism. Worker threads are waiting for each other during a parallel query. Usually harmless, but can indicate that parallelism degree is set too high.
    3. Lock contention on system objects — the tempdb allocation pages (2:1:1, 2:1:2, 2:1:3) can be contended in systems with many small temporary objects. Enable multiple tempdb data files equal to the number of CPU cores.
    4. The queries are genuinely slow — after ruling out locks, refer to Query Profiling and Statistics in SQL: Using EXPLAIN ANALYZE, Buffer Metrics, and Row Estimates to Diagnose Slow Queries for plan-level diagnosis.

    Summary & Next Steps

    Lock contention is a production problem that requires a layered diagnostic approach. You can't fix what you can't see, so we started by building queries against system views that expose blocking chains, lock types, and wait statistics. We then moved to execution plans to understand why those locks are being acquired — the difference between a seek and a scan, between a short transaction and one that calls an API mid-flight, is the difference between no contention and system-wide blocking.

    The resolution toolkit is a stack of increasingly invasive interventions: minimize transaction duration first, add targeted indexes second, adjust isolation levels or use row versioning third, and reach for query hints and architectural changes only when the simpler options won't work. Each intervention requires you to understand what you're trading away: NOLOCK trades correctness for concurrency; SERIALIZABLE trades performance for safety; disabling lock escalation trades memory for granularity.

    The core skill is matching the diagnostic signal to the right intervention. When you see LCK_M_X waits on a specific table from your wait statistics, combined with a head blocker that's been open for 45 seconds, combined with a plan showing a clustered index scan — that's a complete picture that points to a specific fix: shorten the transaction, add an index to enable a seek, or move to RCSI.

    Where to go next:

    • For the full conceptual foundation of how the database engine manages concurrent transactions, work through SQL Transactions, Isolation Levels, and Locking: A Complete Guide to Concurrent Database Programming
    • For deeper work with execution plans beyond locking, see SQL Query Optimization: Reading Execution Plans - Advanced Performance Analysis
    • For index strategy to systematically reduce lock footprint through better access patterns, see Database Performance Tuning: Advanced Indexing Strategies and Query Rewriting for Production Systems
    • For encapsulating your blocking chain diagnostic queries into reusable routines, see Stored Procedures and User-Defined Functions: Building Reusable SQL Logic
    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Advanced SQL Queries

    Previous

    Writing SQL to Simulate LAG and LEAD Across Partition Boundaries: Handling Edge Cases Where Window Functions Return NULL

    Related Insights

    SQLFoundation

    Filtering Rows Before and After Aggregation: WHERE vs HAVING in GROUP BY Queries

    15 min
    SQLPractitioner

    Writing SQL to Simulate LAG and LEAD Across Partition Boundaries: Handling Edge Cases Where Window Functions Return NULL

    22 min
    SQLExpert

    Writing SQL to Detect and Fill Sequence Gaps: Using Self-Joins and Subqueries to Find Missing IDs and Date Ranges

    27 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding What Locking Actually Is (And Why It Goes Wrong)
    • Diagnosing Blocking in SQL Server: The System View Stack
    • Finding Active Blockers Right Now
    • Building a Blocking Chain Tracer with CTEs
    • Reading Lock Details with sys.dm_tran_locks
    • Wait Statistics: The Long View
    • sys.dm_os_wait_stats
    • Diagnosing Blocking in PostgreSQL
    • The pg_locks and pg_stat_activity Join
    • PostgreSQL Wait Event Statistics with pg_stat_activity
    • Reading Query Plans to Understand Lock Acquisition
    • The Index Scan vs. Index Seek Difference Is Critical
    • The Lock Escalation Signal in Plans
    • Transaction Isolation Levels: The Architectural Lever
    • The Standard Isolation Levels and Their Lock Behavior
    • READ COMMITTED SNAPSHOT ISOLATION (RCSI) in SQL Server
    • PostgreSQL's MVCC Architecture
    • Practical Resolution Strategies
    • Strategy 1: Reduce Transaction Duration
    • Strategy 2: Fix the Index to Avoid Broad Lock Acquisition
    • Strategy 3: Rewrite to Reduce Lock Scope
    • Strategy 4: Disable Lock Escalation on Hot Tables
    • Strategy 5: Restructure for Deadlock Prevention with Consistent Lock Ordering
    • Strategy 6: Query Hints as a Last Resort
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Blaming the Wrong Session
    • Mistake 2: Using NOLOCK as a Performance Optimization
    • Mistake 3: Fixing the Symptom (Killing the Blocker) Instead of the Cause
    • Mistake 4: Using Serializable Isolation Unnecessarily
    • Mistake 5: Disabling Lock Escalation Globally
    • Troubleshooting: "I see no blockers but queries are still slow"
    • Summary & Next Steps