Skip to main content
SQL Server · lesson 5 of 7
SQL Server intermediate Lesson 5 of 7

Also part of: Databases

A Real Dirty Read: Seeing a Value That Never Actually Existed

READ UNCOMMITTED reads an uncommitted price change mid-transaction -- then the transaction rolls back, and the value never really existed. The default READ COMMITTED blocks instead, confirmed with a real timeout.

By SkillByExample · Published

sqlserver tsql transactions isolation-levels dirty-read

Two sessions, one uncommitted price change, and a value that gets read, trusted, and then turns out to have never really existed.

Session A: an uncommitted update, held open

-- session_a.sql
BEGIN TRAN;
UPDATE books SET price = 9999.99 WHERE book_id = 1;
WAITFOR DELAY '00:01:00';
ROLLBACK TRAN;
sqlcmd -S localhost -U sa -P '<password>' -C -i session_a.sql &

Running in the background, holding an exclusive lock on book_id = 1 for a full minute before rolling back — a deliberately uncommitted, deliberately temporary change.

Confirming the lock is real, not assumed

SELECT CAST(resource_type AS VARCHAR(10)) AS resource_type,
       CAST(request_mode AS VARCHAR(6)) AS request_mode,
       CAST(request_status AS VARCHAR(6)) AS request_status
FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID('bookshop');
resource_type request_mode request_status
------------- ------------ --------------
DATABASE      S            GRANT
PAGE          IX           GRANT
KEY           X            GRANT
OBJECT        IX           GRANT

(4 rows affected)

A real X (exclusive) lock on the row’s key, actually held right now — not assumed from timing. Waiting for this to appear (rather than guessing how long session A takes to start) is what makes the rest of this lesson reliable regardless of how fast either session happens to run.

The bug: reading a value that will never really exist

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT price FROM books WHERE book_id = 1;
price
--------
9999.99

(1 rows affected)

9999.99 — session A’s uncommitted change, read directly while the exclusive lock is still held. READ UNCOMMITTED ignores other transactions’ locks entirely, so it sees writes that haven’t been (and might never be) committed.

What the default isolation level does instead

SELECT price FROM books WHERE book_id = 1;
[blocks -- no output within 5 seconds, timeout exit code 124]

No dirty read this time — the default READ COMMITTED isolation level requires a shared lock to read the row, and session A’s exclusive lock is incompatible with that. The read simply waits. This isn’t a failure; the 5-second timeout used to demonstrate it just ran out while the read was still genuinely blocked.

The value that never existed

-- after session A's WAITFOR DELAY elapses and it rolls back
SELECT price FROM books WHERE book_id = 1;
price
--------
11.99

(1 rows affected)

11.99 — the real, original price. 9999.99, read moments earlier under READ UNCOMMITTED, was never actually committed to the database at any point in its history. A dirty read isn’t “reading slightly stale data” — it can be reading data that turns out to have never truly existed at all.

Practice

1. Achieve the same dirty read with a table hint instead of a session-level isolation level.
SELECT price FROM books WITH (NOLOCK) WHERE book_id = 1;
price
--------
9999.99

(1 rows affected)

WITH (NOLOCK) is the per-table equivalent of READ UNCOMMITTED — same dirty-read behavior, scoped to just this one table reference instead of the whole session.

2. Inspect a blocked query from outside, without waiting for it to finish.
SELECT session_id, blocking_session_id, CAST(wait_type AS VARCHAR(20)) AS wait_type
FROM sys.dm_exec_requests
WHERE blocking_session_id != 0;
session_id blocking_session_id wait_type
---------- ------------------- --------------------
        56                  51 LCK_M_S

(1 rows affected)

Session 56’s read is blocked by session 51 (session A), waiting specifically on LCK_M_S — a shared-lock request. This is the direct way to diagnose “why is this query stuck,” instead of inferring it from a timeout alone.

3. Confirm the row's value once session A's rollback actually completes.
SELECT COUNT(*) FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID('bookshop') AND request_mode = 'X';
-----------
          0

(1 rows affected)

0 — the exclusive lock is gone, confirming session A’s transaction actually finished (rolled back) rather than assuming it did after a fixed wait. Only after seeing this is it safe to trust a subsequent read of the row as the real, settled value.

Next: stored procedures and triggers.

Frequently Asked Questions

What exactly is a dirty read?
Reading a value another transaction has written but not yet committed — if that transaction later rolls back, the value you read never actually existed in the database at all. This lesson's SELECT under READ UNCOMMITTED reads 9999.99, and the row's real, final value turns out to be 11.99 once the other transaction rolls back.
Why did the default isolation level block instead of returning a value?
READ COMMITTED (the default) takes a shared lock to read a row, and the other transaction's UPDATE already holds an exclusive lock on that same row — the two are incompatible, so SQL Server makes the reader wait until the exclusive lock is released, rather than either reading a possibly-wrong value or reading nothing. The timeout in this lesson didn't happen because anything failed; it happened because the read was genuinely still waiting when the timeout ran out.
Is WITH (NOLOCK) the same as SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED?
For a single table reference, yes — WITH (NOLOCK) is a per-table hint with the same read-uncommitted-and-ignore-locks behavior, while the SET statement changes the isolation level for every statement in the session until changed back. NOLOCK is more surgical when only one query in a session should risk a dirty read; the session-level SET is simpler when every query in a session should.
How can I tell what a blocked query is actually waiting on?
sys.dm_exec_requests has a blocking_session_id column — nonzero means that request is currently blocked by another session, and joining wait_type tells you what kind of lock it's waiting for (LCK_M_S for a shared lock, in this lesson's case). This is the direct way to diagnose "why is this query just sitting there" instead of guessing.
Why does this lesson poll sys.dm_tran_locks instead of just using a fixed sleep to coordinate the two sessions?
A fixed sleep assumes the other session's transaction has started and taken its lock by a certain point, which is a timing guess that can be wrong under different load or scheduling — polling for the actual lock (request_mode = 'X' on the target resource) waits for a real, observable fact instead of a guess, so the demonstration is reliable regardless of how fast or slow the other session happens to start.