skip to content

A database engine can take locks at row, page, or whole-table granularity. What are the trade-offs, and what makes an engine choose a coarser or finer level?

level: middleimportance: must knowfreq 58%

answer

  1. Fine = concurrency, coarse = cheap bookkeeping
  2. Page locks → false contention from physical adjacency
  3. Row count touched drives the choice
  4. PostgreSQL/InnoDB never escalate; SQL Server does
  5. Missing index → scan → mass locking

basics

~20 s

Fine granularity (row) maximises concurrency but costs memory and CPU per lock; coarse granularity (table) is nearly free to track but serialises unrelated work. Engines choose by how many rows a statement touches — few rows, lock rows; whole table, lock the table.

solid answer

~60 s

Granularity trades **concurrency against overhead**. A row lock lets a thousand transactions work on a thousand different rows of the same table simultaneously, but each lock is a lock-manager entry costing memory (typically tens to a couple hundred bytes) plus hash-table CPU on acquire, release and conflict check. A table lock is one entry regardless of table size — trivially cheap and instantly checkable — but any other writer to that table waits. Page locks are the historical middle ground: one entry covers every row on the page, which is efficient but creates **false contention**, since rows that merely happen to be neighbours block each other. Engines pick based on the estimated footprint of the access path. An indexed single-row `UPDATE` locks rows; a `DELETE FROM t` with no predicate is cheaper to serve under one table lock; DDL takes the table exclusively. Modern engines like PostgreSQL and InnoDB lock rows and never escalate, spending memory instead; SQL Server and DB2 escalate to table locks past a threshold. The tuning lever the application controls is **how many rows a statement touches** — narrow predicates on good indexes.

go deeper

for a junior

Name the three levels and the one-line trade-off: finer means more concurrency and more overhead, coarser means less of both.

for a middle

Add the mechanics — per-lock memory and CPU, false contention on page locks, and the fact that the number of rows the access path touches drives the engine's choice.

for a senior

Connect it to production: a missing index escalates a small update into a table-wide blocking event, and batching bounds the lock set. Note which engines escalate and which never do.

for a principal

Argue the design trade: never-escalate gives predictable concurrency with an unbounded memory tail; escalation gives bounded memory with a sudden concurrency cliff. Tie the choice to workload shape and to how you would bound worst-case batch behaviour.

## Why granularity is a dial at all Every lock protects a *resource*, and resources nest: a database contains tables, a table contains pages (fixed-size blocks, typically 8 or 16 KB), a page contains rows. The engine can place its lock at any level of that hierarchy. The choice is a pure engineering trade between two costs that pull in opposite directions. ## The cost of fine granularity A row lock is the finest practical unit in mainstream engines. Its benefit is obvious: two transactions touching different rows never wait for each other, so a hot table can absorb thousands of concurrent writers. Its costs are real but easy to overlook: - **Memory.** Each lock is an entry in the lock manager — roughly 100–250 bytes in engines that keep locks in a shared structure. A single statement that updates ten million rows would need ten million entries, which is why unbounded row locking is a genuine memory-exhaustion risk. - **CPU.** Acquire, conflict-check and release are hash-table operations under a latch. Ten million of them is not free, and the latch on hot hash buckets can itself become a scalability bottleneck on many-core machines. - **Conflict-check cost at coarse levels.** If rows are locked individually, how does a transaction that wants the *whole table* know whether anything inside is locked? Naively it would have to scan every row lock. This is precisely the problem intent locks exist to solve. InnoDB sidesteps part of the memory cost by storing lock bits per page-of-records rather than one heap object per row; PostgreSQL sidesteps it differently, marking the row's own header with the locking transaction id so a row-level write lock costs no lock-manager entry at all. Both are optimisations of the same idea. ## The cost of coarse granularity A table lock is one entry. Checking it is O(1). Memory is constant. It is the cheapest possible bookkeeping — and it serialises every writer to the table, including transactions working on completely unrelated rows. On a hot OLTP table that is catastrophic for throughput; on a nightly bulk load into a table nobody else is reading, it is free performance. ## The page-level middle ground Page locks were common in older engines (and remain a level SQL Server can use). One entry covers every row that happens to live on the page, which is a real win on scans. The failure mode is **false contention**: two transactions updating logically unrelated rows collide only because the storage layer placed those rows in the same block. Because physical layout is invisible to the application, the resulting blocking looks random and is very hard to reason about — a big reason modern engines defaulted to row locks. ## How the engine decides The input is the **estimated number of rows the access path will touch**: - Indexed lookup of a handful of rows → row locks. - Full scan with a modifying statement → the engine may take row locks and then escalate, or take a table lock up front, depending on the product. - `TRUNCATE`, `ALTER TABLE`, index rebuild → table-level exclusive lock, because the object's structure itself is changing. This is why plan quality and locking behaviour are coupled. A missing index turns "lock 3 rows" into "scan and lock the table", and the symptom the on-call engineer sees is not a slow query but a blocking storm. ## What the two families do differently PostgreSQL and MySQL/InnoDB lock rows and **never escalate** — they will happily hold millions of row locks and spend the memory. SQL Server and DB2 **escalate**: past a per-statement threshold (around 5,000 locks in SQL Server, plus memory-pressure triggers) they trade the accumulated fine locks for one coarse lock, deliberately buying memory back with concurrency. Both designs are defensible. Never escalating gives predictable concurrency and an unbounded memory tail; escalating gives bounded memory and a concurrency cliff that arrives suddenly under load. ## What the application actually controls You rarely set granularity directly, and hint-forcing it is usually a smell. The levers that matter: 1. **Touch fewer rows per statement** — selective predicates, supporting indexes. 2. **Batch large mutations** — update 5,000 rows per transaction in a loop rather than 5,000,000 in one, which caps lock count, keeps transactions short and avoids escalation thresholds. 3. **Keep transactions short** — granularity determines *how much* is locked, but duration determines *for how long*, and duration is usually the bigger production problem. 4. **Schedule DDL deliberately**, since it takes the coarsest lock there is. ## What interviewers listen for The trade-off stated in both directions (concurrency vs. per-lock overhead), awareness that page locks create false contention that the application cannot see or predict, and — the senior signal — connecting granularity back to access-path selection: the fastest way to make an engine lock too much is to make it scan too much.

  • How do index-key locks fit into the row/page/table hierarchy?
    Index entries are lockable resources in their own right, and locking them is what prevents phantoms. Engines lock the index key or the gap between keys so a concurrent insert cannot appear inside a range another transaction has already scanned. That is why a range update on an indexed column can block inserts of rows that do not yet exist.
  • A batch job updates 20 million rows in one transaction and the database runs out of lock memory. What do you change?
    Chunk it — commit every few thousand rows so the lock set is bounded and released regularly, driving each chunk off an indexed key range. That caps memory, keeps replication lag and undo growth in check, and avoids escalation thresholds. If the job genuinely must be atomic, take an explicit table lock deliberately during a maintenance window instead of accumulating millions of row locks.

saying these in an interview costs you the question

  • "Row locks are always better" — ignores the memory, CPU and lock-manager latch cost of millions of them
  • Thinking page locks are just smaller table locks, missing that they cause contention between logically unrelated rows
  • Assuming the developer chooses granularity per statement; in practice the access path and the engine choose it
  • Believing a table lock is a bug or misconfiguration — it is the correct, cheap choice for DDL and bulk loads
  • Confusing granularity with duration: locking less does not help if the transaction stays open for minutes

context