October 31, 2026
NOLOCK: what it actually risks beyond dirty reads
Everyone reaches for NOLOCK knowing it can show uncommitted data. Fewer know it can also show you the same row twice, skip a row entirely, or throw an error mid-scan — and none of those are the dirty-read tradeoff people think they're accepting.
WITH (NOLOCK) (equivalent to running the statement under READ UNCOMMITTED) is reached for constantly in T-SQL — usually as a reflexive
"this query is blocked / this is a reporting query, slap NOLOCK on it" fix
— on the understood tradeoff of "I might see a row that hasn't committed
yet." That tradeoff is real, and it's also not the whole story. The part
that doesn't get mentioned nearly as often: NOLOCK can make a single
scan return a row twice, skip a row entirely, or fail outright —
none of which is "seeing uncommitted data," and none of which most people
signing off on NOLOCK think they're agreeing to.
What dirty reads actually are (the risk everyone knows)
SELECT status FROM orders WITH (NOLOCK) WHERE order_id = 101;If another session has an open, uncommitted transaction that changed this
row's status and hasn't committed or rolled back yet, this query can
return that uncommitted value — including a value that the other
transaction later rolls back, meaning you read something that, from the
database's actual committed history, never happened. This is the
well-known risk, and for a lot of reporting/dashboard use cases, it's a
genuinely acceptable tradeoff: slightly-possibly-stale numbers in exchange
for not blocking on (and not being blocked by) writers.
What's less well known: duplicate and missing rows
NOLOCK doesn't just skip taking locks on the rows it reads — it also
skips the locks that would normally prevent the data it's scanning
from moving around underneath it. If a concurrent write causes a page
split (SQL Server reorganizing a full data page into two to make room
for a new row) while a NOLOCK scan is in progress, rows can move to a
different physical location mid-scan. Depending on whether that
relocation happens ahead of or behind the scan's current position:
- A row that moves behind where the scan has already passed can be read again when the scan reaches its new location — the same logical row, counted twice in an aggregate, returned twice in a result set.
- A row that moves ahead of where the scan currently is, into territory the scan has already passed, can be skipped entirely — never read at all, with nothing in the query's output indicating a row went missing.
Neither of these is a dirty read. The data involved might be fully
committed, unrelated to any transaction you'd think to worry about — the
problem is purely that the scan's own position and the table's physical
layout moved out of sync with each other, something NOLOCK specifically
allows to happen by skipping the locks that would otherwise prevent it.
It can also just error
Less common, but documented: a NOLOCK scan can encounter data that's
moved or been deallocated in a way the scan can't reconcile, and raise an
error mid-query (classically surfaced as an 601-family error referencing
being unable to continue a scan due to data movement) rather than
silently producing a wrong result. From an application's point of view
this is almost the better outcome — a visible failure instead of quietly
wrong numbers — but it means NOLOCK queries need real error handling
around them regardless, not just an assumption that they'll always
succeed, just maybe with stale data.
Where this actually matters vs. where it doesn't
Low risk: a mostly-static or append-only table, or a report running during a quiet window with little concurrent write activity — fewer page splits in flight means fewer chances for a row to move mid-scan.
Real risk: a financial aggregate, inventory count, or anything where
"exactly once, every row" is load-bearing, run against a table under
active concurrent write load — a SUM() that's occasionally double- or
under-counting a handful of rows, with no error and no indication
anything was wrong, is a worse failure mode than the dirty-read case most
people think they're signing up for.
The alternatives, when NOLOCK's actual tradeoff isn't acceptable
READ COMMITTED SNAPSHOT(database-level setting) — readers see a transactionally-consistent snapshot as of the start of their statement, with no dirty reads and no blocking against concurrent writers, at the cost oftempdbversion-store overhead. For a database where write concurrency and reporting both matter, this is usually the better default than reaching forNOLOCKquery-by-query.- A narrower, indexed query that reduces lock duration and scope in the first place, so there's less to block on and NOLOCK's tradeoffs become unnecessary rather than worked around.
- Accept the tradeoff explicitly, with eyes open —
NOLOCKis a legitimate choice for genuinely low-stakes reporting against a low-write-volume table; the point here isn't "never use it," it's "know what it actually risks before reaching for it as a reflex."
Can't demonstrate this in the playground
Reproducing a NOLOCK duplicate/missing row requires a concurrent writer
causing a real page split during an in-flight scan under SQL Server's
specific storage engine behavior — SQLite has no equivalent locking mode
and no concurrent-session model in this single-connection sandbox to
demonstrate the race against. This one has to be taken on the documented
behavior, or reproduced against a real SQL Server instance under genuine
concurrent write load.
- Isolation levels — READ UNCOMMITTED in context alongside the other three standard isolation levels.
- Deadlocks in SQL Server — the concurrency problem NOLOCK is usually reached for to avoid in the first place.
- Transactions basics — what a transaction and its isolation level actually control.