Topic: Databases

Concrete problem

A hospital rule says: at least one doctor must stay on call. The table doctors has two rows. Alice and Bob both have on_call = true.

Both doctors feel ill. Each doctor runs this transaction at the same moment:

BEGIN;
SELECT count(*) FROM doctors WHERE on_call;   -- returns 2, so it is safe to leave
UPDATE doctors SET on_call = false WHERE name = '<me>';
COMMIT;

Both transactions commit. Now zero doctors are on call. The rule is broken.

Write-Write Conflict and Row Lock does not help. Alice updates her own row. Bob updates his own row. No two writers touch the same row. No writer waits. Each transaction made a correct decision from its own snapshot. The two decisions together are wrong.

How can the database stop this without a lock on the rows?

Background terms

  • Isolation level: The isolation level of a transaction says how much the transaction sees of other transactions that run at the same time. Postgres has three levels that differ in behavior: READ COMMITTED (the default), REPEATABLE READ, and SERIALIZABLE. The first two show a snapshot of committed data. SERIALIZABLE adds a check on top of the snapshot.
  • Serializable: A set of transactions is serializable when the result equals the result of running them one at a time, in some order. Any order is acceptable. The database does not have to run them one at a time. It only has to give a result that one order could have given.
  • Write skew: Write skew is a wrong result that two transactions cause together. Each transaction reads some rows. Each transaction then writes a row that the other transaction read. The two writes touch different rows, so no write-write conflict occurs. The doctors example is write skew.
  • Read-write dependency: A read-write dependency from transaction to transaction exists when read data and wrote new data that did not see. used a snapshot that does not hold the write of . So in any one-at-a-time order, must run before . We write .
  • SIREAD lock: An SIREAD lock is a record that says “this transaction read this data”. Postgres uses it only to find read-write dependencies. An SIREAD lock never blocks anyone. The name means “serializable read”.
  • Predicate lock: A predicate lock covers every row that matches a condition. It also covers rows that do not exist yet. An SIREAD lock is a predicate lock. This matters for a query that finds no rows. The query still read “the set of rows with this condition”, and a later INSERT can change that set.
  • Serialization failure: A serialization failure is an error with SQLSTATE 40001. Postgres raises it when it aborts a transaction to keep the result serializable. The transaction did nothing wrong by itself. The application must run the whole transaction again.

Intuition

The goal is a result that one order could give. So we ask: is there a valid one-at-a-time order for Alice and Bob?

  1. Alice read Bob’s row. Bob then wrote Bob’s row. Alice did not see the write. So Alice must come before Bob: .
  2. Bob read Alice’s row. Alice then wrote Alice’s row. Bob did not see the write. So Bob must come before Alice: .
  3. Alice before Bob and Bob before Alice has no answer. This is a cycle.

A cycle of read-write dependencies means that no valid order exists. Then Postgres must abort one transaction. The other transaction commits. If Bob aborts and runs again, he sees that Alice is off call. He sees a count of and stays on call. The rule holds.

How does Postgres find the cycle without blocking anyone? It records and checks:

  1. A transaction that reads takes an SIREAD lock on what it read. It does not wait for anything.
  2. A transaction that writes looks for SIREAD locks on the data it writes. A match means a read-write dependency exists. Postgres notes the dependency. The writer does not wait.
  3. Postgres looks for a dangerous pattern: transactions in a row, where commits first. The middle transaction is the pivot. Every cycle contains this pattern. When Postgres finds it, it aborts one transaction with 40001.

Postgres checks a pattern, not the full cycle. So it sometimes aborts a transaction when no real cycle exists. This is a false positive. The cost is a retry. A false negative cannot happen.

Compare this with N1 to N6. There, a lock made a transaction wait. A conflict was prevented by ordering. Here, no transaction waits for an SIREAD lock. Transactions run freely. Postgres detects a bad result and aborts one transaction. This is an optimistic method. It costs less when conflicts are rare. It costs a retry when they are not.

Ordinary row locks still exist at SERIALIZABLE. Two UPDATEs of one row still wait for each other. Only the SIREAD locks never wait.

Where do SIREAD locks live? In the shared memory lock table, as in Where Locks Live. pg_locks shows them with mode = 'SIReadLock'. They differ from other locks in one way: Postgres keeps an SIREAD lock after the transaction commits. A later writer can still form a dependency with a committed reader, as long as another transaction that overlapped with the reader still runs. Postgres removes the lock after all of those transactions end.

SIREAD locks start small and grow coarse. Postgres locks a row (tuple) first. When one transaction holds many tuple locks on a page, Postgres merges them into one page lock. Many page locks merge into one relation lock. This promotion saves memory in the lock table. A coarse lock covers more data than the transaction read. So promotion causes more false positives.

Numeric example

Table doctors: Alice true, Bob true. Both transactions use BEGIN ISOLATION LEVEL SERIALIZABLE.

StepAlice’s transaction Bob’s transaction What Postgres records
1SELECT count(*) ... WHERE on_call returns 2 takes an SIREAD lock on both rows.
2SELECT count(*) ... WHERE on_call returns 2 takes an SIREAD lock on both rows.
3UPDATE ... WHERE name = 'Alice' writes a row that read. Dependency .
4UPDATE ... WHERE name = 'Bob' writes a row that read. Dependency . Now a cycle exists.
5COMMIT succeeds
6COMMIT fails with 40001 (or an earlier statement fails)Postgres aborts .
7Application runs again. count(*) returns 1. The rule stops the update.

At REPEATABLE READ, steps 1 to 6 all succeed. No SIREAD locks exist. The final result is zero doctors on call.

Phantom case. Two transactions book room 1 at 10:00. Each runs SELECT ... WHERE room = 1 AND slot = '10:00'. Each query finds no row. Each transaction then inserts its own booking. No row existed to lock, so FOR UPDATE would lock nothing. The predicate lock covers “room 1, slot 10:00” even though no row matches. Each INSERT hits the other transaction’s predicate lock. A cycle forms. One transaction aborts.

Visual

graph LR
  subgraph Locks["N1 to N6: lock makes a transaction wait"]
    W1["Writer 1 holds row lock"] -->|"Writer 2 waits"| W2["Writer 2 blocked"]
  end
  subgraph Ssi["N7: SIREAD lock only records a read"]
    TA["T_A: read both rows, write Alice"]
    TB["T_B: read both rows, write Bob"]
    TA -->|"read-write dependency"| TB
    TB -->|"read-write dependency"| TA
    TB --> X["Cycle: abort one with 40001"]
  end
graph TD
  A["Read by a transaction"] --> B["SIREAD lock on a tuple"]
  B -->|"many tuples on one page"| C["SIREAD lock on a page"]
  C -->|"many pages of one table"| D["SIREAD lock on the relation"]
  D --> E["Covers more than was read: more false positives"]

Decision chart: what happens to two overlapping transactions?

Start at the top. Answer each question. The box at the end gives the result.

flowchart TD
  S["Two transactions overlap"] --> Q1{"Do both WRITE the same row?"}
  Q1 -->|"Yes"| W["The second writer WAITS for the first to end"]
  W --> C{"The first one commits. What is the isolation level of the second writer?"}
  C -->|"READ COMMITTED"| RC["Takes a new snapshot. Updates the new version. No error."]
  C -->|"REPEATABLE READ or SERIALIZABLE"| E1["Snapshot is old. Fails with 40001."]
  Q1 -->|"No"| Q2{"Does a transaction WRITE a row that the other one READ?"}
  Q2 -->|"No"| OK0["No link. Both commit."]
  Q2 -->|"Yes, one direction only"| OK1["One dependency. A valid order exists. Both commit. Nobody waits."]
  Q2 -->|"Yes, in both directions"| Q3{"What is the isolation level?"}
  Q3 -->|"SERIALIZABLE"| E2["Cycle. No valid order. One fails with 40001."]
  Q3 -->|"REPEATABLE READ or lower"| BAD["Both commit. Write skew. The data is wrong."]

Three facts to keep:

FactMeaning
Only a writer of the same row makes you waitA read never makes a writer wait.
One arrow is safe means “run first”.
Two opposite arrows are a cycleNo order works. One transaction fails.

Notation

Start a serializable transaction:

BEGIN ISOLATION LEVEL SERIALIZABLE;

A read-only transaction that never fails with 40001:

BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;

DEFERRABLE makes the transaction wait at its start until Postgres can give it a snapshot that no cycle can involve. After that, it takes no SIREAD locks and cannot fail.

See SIREAD locks:

SELECT locktype, relation::regclass, page, tuple, pid
FROM pg_locks
WHERE mode = 'SIReadLock';

The locktype column shows tuple, page, or relation. This shows the granularity.

The error to catch: SQLSTATE 40001, message could not serialize access due to read/write dependencies among transactions.

Plain-English translation

  • "" means ” read something that then changed. must come first in any one-at-a-time order.”
  • “A cycle” means “no valid one-at-a-time order exists”.
  • “Pivot” means “the middle transaction of a chain of two dependencies”.
  • “Take an SIREAD lock” means “write down what I read. Do not stop anyone.”
  • “Serialization failure” means “run the whole transaction again”.

What changes when parameters change

  • Lower isolation level. At REPEATABLE READ, write skew is possible. Postgres takes no SIREAD locks. Write skew does not raise an error. The data is wrong.
  • Writers touch the same row. An ordinary row lock applies. The second writer waits, as in Write-Write Conflict and Row Lock. After the first writer commits, the second writer gets a serialization failure in place of a silent re-read.
  • Many tuples read. SIREAD locks promote to page locks and then to a relation lock. The lock table stays small. False positives rise. The server settings max_pred_locks_per_transaction, max_pred_locks_per_relation, and max_pred_locks_per_page control the promotion thresholds.
  • A long transaction. It keeps its SIREAD locks, and the locks of committed overlapping readers, for a long time. More dependencies form. More aborts occur. Keep serializable transactions short.
  • A mix of levels. The check works only among SERIALIZABLE transactions. A REPEATABLE READ transaction takes no SIREAD locks and can still cause write skew.
  • A read-only transaction. It can fail with 40001 when it is the start of a dependency chain. DEFERRABLE removes this risk and costs a wait at the start.
  • No retry code. The application sees random 40001 errors. Retry code is part of the feature, not an extra.

Practice problem

Try these on a later review. Do not open the answers first.

  1. Two bank accounts and each hold 50. The rule is: the sum must stay at or above 0, so each account alone may go to . Transaction reads both balances, then withdraws 90 from . Transaction reads both balances, then withdraws 90 from . They overlap. What is the final sum at REPEATABLE READ? What happens at SERIALIZABLE?
  2. A report transaction only runs SELECT. The team sees it fail with 40001 once a week. Why can a read-only transaction fail? Which syntax stops it?
  3. Transaction commits at SERIALIZABLE. You run the pg_locks query for SIReadLock and still see locks that belong to . Is this a bug?
  4. Your service sets SERIALIZABLE for every transaction. The error rate for 40001 rises when a query scans a whole large table. Why? Give one fix at the application level and one at the schema level.

Quiz

Asked on 2026-10-05 after the lesson. Two answers were correct and one was a miss. A re-check followed and was also a miss.

Correction after the 2026-10-06 review

The review missed the same two gaps again. Each gap has one test question.

Gap 1: can a writer build on a version it cannot see?

  1. A snapshot is the set of versions that a transaction can see.
  2. At READ COMMITTED, each statement takes a new snapshot. After commits, sees the new version. So updates it.
  3. At REPEATABLE READ and SERIALIZABLE, the snapshot is fixed at the first statement. The new version is not in it.
  4. An update of that row would erase a change that never saw. This is a lost update. Postgres refuses and fails with 40001.

Test question: “Does the transaction’s snapshot contain the newest version of the row?” If no, and the transaction wants to write it, the result is 40001.

Gap 2: one dependency is not an error.

  1. A read-write dependency says only: ” must come before ”.
  2. One such order is valid. Run , then . The result is the same.
  3. An error is needed only when no order exists. That needs a cycle. Two opposite dependencies make a cycle.

Test question: “Can I write the transactions in one order that gives the same result?” If yes, no error.

Gap 3: a read never makes a writer wait.

  1. An SIREAD lock is a note: “this transaction read this”. It is not a row lock.
  2. A writer waits only for another writer of the same row (or for a table lock that conflicts).
  3. A reader that holds an SIREAD lock never makes anyone wait. At most it adds one dependency. See Gap 2.

Test question: “Is the other transaction a writer of this row?” If it only read the row, nobody waits.

My reasoning

The Q1 miss came from mixing two sources of 40001. The user named it: the write-write case. The re-check showed a narrow gap inside that case. The user applied the READ COMMITTED rule (re-read the newest version) to SERIALIZABLE. Rules to keep:

  • Same row, second writer: waits. At READ COMMITTED it then continues. At REPEATABLE READ and SERIALIZABLE it fails with 40001.
  • Different rows, read-write cycle: no wait. One transaction fails with 40001. Only SERIALIZABLE checks this.
  • One dependency alone is never an error.

Session history