PostgreSQL Interview Questions and Answers Part 7 - Transactions, MVCC and locks

PostgreSQL Interview Q&A · Part 7 of 10

In this post we are going to cover transactions, MVCC and locks in PostgreSQL: row versions, isolation levels, lost updates, deadlocks, SKIP LOCKED, the lock queue behind an ALTER TABLE, and how to find a blocking session. These are 7 real PostgreSQL interview questions, asked the way they come up on the job.

Concurrency questions separate people who have run Postgres in production from people who have only written queries. To make every answer concrete, each example below uses two sessions, session A and session B, and every result, wait and error message is real. To follow along, open two psql windows.

Every query below is complete, so you can copy it, run it on the sample data and get exactly the output shown under it.

VIDEO COMING SOON

Part 7 of the Real Time PostgreSQL Interview Questions and Answers series on TechBrothersIT.

Let's create some sample data

For this post we use two small tables: accounts, with three bank accounts, and jobs, a queue of five jobs. Open two database sessions, A and B, to follow along. Each code box says which session runs it. The script below creates everything this post needs. It was written for and run on PostgreSQL 16.

Sample data - SQL script (PostgreSQL)
CREATE TABLE accounts (id INT PRIMARY KEY, owner TEXT, balance INT);
INSERT INTO accounts VALUES (1, 'Sara', 1000), (2, 'Omar', 500), (3, 'Lina', 750);

CREATE TABLE jobs (job_id INT PRIMARY KEY, payload TEXT, status TEXT DEFAULT 'queued');
INSERT INTO jobs (job_id, payload) VALUES
  (1, 'send invoice 1001'), (2, 'send invoice 1002'), (3, 'send invoice 1003'),
  (4, 'send invoice 1004'), (5, 'send invoice 1005');

Question 1: A long report reads the accounts table while a batch job updates the same rows. In some databases the report would wait. In Postgres it does not. Why?

Say this first

Because of MVCC, multi-version concurrency control. An UPDATE does not overwrite the row. It writes a new version and keeps the old one. Every query reads the versions that were committed when its snapshot was taken, so readers and writers do not block each other.

Here is one row during an update. Version one says the balance is 1000. Session A updates it. Postgres does not touch version one, except to stamp it with A's transaction ID as its end. It writes version two, with balance 900. Session B reads now. A has not committed, so B's snapshot still sees version one: 1000. No waiting. After A commits, new snapshots see version two, and version one is dead, waiting for vacuum.

the row with id = 1
Version 1 · balance 1000xmin = old transaction
xmax = A (ended by A)
Version 2 · balance 900xmin = A (created by A)
xmax = 0
Session A: UPDATE ... balance = 900writes version 2, not committed yet
Session B: SELECT balance → 1000its snapshot ignores uncommitted versions
A commits → new queries see 900version 1 is now a dead tuple

Let's do it for real. Session A opens a transaction, takes 100 from account one, and does not commit.

SQL - a update
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

Result: UPDATE 1

Session B reads the same row at that moment. It answers immediately, with the old balance, 1000. And the hidden columns show why: B still sees the old version, and its xmax is now set, by A's transaction.

SQL - b read (session B)
SELECT ctid, xmin, xmax, balance
FROM accounts
WHERE id = 1;
Output (PostgreSQL 16)1 row
ctidxminxmaxbalance
(0,1)207920821000

Session A reads its own change: a new version at a new position, created by its own transaction, with balance 900. Then A commits.

SQL - a read
SELECT ctid, xmin, xmax, balance
FROM accounts
WHERE id = 1;
Output (PostgreSQL 16)1 row
ctidxminxmaxbalance
(0,4)20820900

The price of MVCC is the dead versions it leaves behind. Cleaning them up is the job of vacuum, and that is why a long open transaction is dangerous: it forces Postgres to keep old versions alive.

Question 2: A finance report runs two queries inside one transaction. The total changes between the first query and the second, because another session committed in between. Is that a bug, and how do you get a consistent report?

Say this first

It is the default level, READ COMMITTED: every statement gets a fresh snapshot, so it sees whatever was committed before it started. For a consistent report use REPEATABLE READ: one snapshot for the whole transaction.

Compare the two levels on a timeline. The report reads a total of 2250. Another session adds 500 and commits. In Read Committed, the report's second query takes a new snapshot and sees 2750. The two numbers in one report do not match. In Repeatable Read, the second query reuses the first snapshot and still sees 2250.

time
READ COMMITTED report
Other session
REPEATABLE READ report
t1
SELECT SUM → 2250
SELECT SUM → 2250
t2
UPDATE +500; COMMIT
t3
SELECT SUM → 2750
SELECT SUM → 2250
t4
new snapshot per statement
one snapshot per transaction

First the default. Session A begins and reads the total: 2250.

SQL - rc first
BEGIN;   -- READ COMMITTED is the default
SELECT SUM(balance) AS total FROM accounts;
Output (PostgreSQL 16)1 row
total
2250

Session B adds 500 to account two. It is in autocommit mode, so the change is committed at once.

SQL - rc other (session B)
UPDATE accounts
SET balance = balance + 500
WHERE id = 2;

Result: UPDATE 1

Session A repeats exactly the same query, still inside the same transaction. 2750. The total moved under the report's feet.

SQL - rc second
SELECT SUM(balance) AS total FROM accounts;
Output (PostgreSQL 16)1 row
total
2750

Now Repeatable Read. Session A begins with that level and reads 2750.

SQL - rr first
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(balance) AS total FROM accounts;
Output (PostgreSQL 16)1 row
total
2750

Session B commits another 500. Session A reads again: still 2750. Its snapshot is frozen until the transaction ends.

SQL - rr other (session B)
UPDATE accounts
SET balance = balance + 500
WHERE id = 2;

Result: UPDATE 1

SQL - rr second
SELECT SUM(balance) AS total FROM accounts;
Output (PostgreSQL 16)1 row
total
2750

Two facts interviewers like. In Postgres, Read Uncommitted behaves like Read Committed, so there are no dirty reads. And in Repeatable Read, if you try to update a row that someone else changed after your snapshot, you get a serialization error and must retry.

Interview tip

No dirty reads in Postgres, ever. Repeatable Read writers must be ready to retry.

Question 3: Two workers withdraw money from the same account at the same time. One takes 100, the other takes 200. The balance goes from 1000 to 800 instead of 700. Where did the 100 go?

Say this first

It is a lost update. Both workers read 1000, calculated in the application, and wrote their own result, so the second write erased the first. Fix it with an atomic UPDATE, or lock the row first with SELECT ... FOR UPDATE.

Read, calculate, write, in two sessions at once. Both read 1000. A calculates 900 and writes it. B calculates 800 from its old read, and writes that. A's withdrawal is gone. With FOR UPDATE, B's read has to wait until A is finished, so B reads 900, and writes 700.

time
Worker A (takes 100)
Worker B (takes 200)
t1
SELECT balance → 1000
SELECT balance → 1000
t2
UPDATE balance = 900
t3
UPDATE balance = 800
t4
the 100 is lost
should be 700
SELECT ... FOR UPDATE locks the row at read time: B waits, reads 900, writes 700.

Here is the bug for real. Both sessions read the balance. Both get 1000.

SQL - lu read a
SELECT balance FROM accounts WHERE id = 1;
Output (PostgreSQL 16)1 row
balance
1000
SQL - lu read b (session B)
SELECT balance FROM accounts WHERE id = 1;
Output (PostgreSQL 16)1 row
balance
1000

A writes 900. B writes 800. And the final balance is 800.

SQL - lu write a
UPDATE accounts SET balance = 900 WHERE id = 1;
SQL - lu write b (session B)
UPDATE accounts SET balance = 800 WHERE id = 1;
SQL - lu final (session B)
SELECT balance FROM accounts WHERE id = 1;
Output (PostgreSQL 16)1 row
balance
800

The simplest fix is to let the database do the arithmetic. Each session subtracts in the UPDATE itself. The second UPDATE waits for the first, then works on the new value. 700. Correct.

SQL - atomic a
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
SQL - atomic b (session B)
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
SQL - atomic final (session B)
SELECT balance FROM accounts WHERE id = 1;
Output (PostgreSQL 16)1 row
balance
700

When the application must read first, for example to check a limit, open a transaction and read with FOR UPDATE. That locks the row until you commit. A version column with a check in the WHERE clause, called optimistic locking, is the third option.

Interview tip

Read-then-write? SELECT ... FOR UPDATE inside a transaction, or a version column.

Question 4: The application log shows: ERROR, deadlock detected. Two money transfers ran at the same time. What is a deadlock, what does Postgres do about it, and how do you prevent it?

Say this first

Two transactions each hold a lock the other one needs, so neither can finish. Postgres detects the cycle after deadlock_timeout, one second by default, and aborts one of them. Prevent it by locking rows in the same order, and retry the aborted transaction.

Two transfers in opposite directions. A locks account one. B locks account two. Now A wants account two, which B holds. A waits. And B wants account one, which A holds. B waits too. Each one is waiting for the other, forever. After one second Postgres checks the wait graph, finds the cycle, and cancels one transaction, so the other can finish.

time
Session A: transfer 1 → 2
Session B: transfer 2 → 1
t1
UPDATE account 1 (locks 1)
UPDATE account 2 (locks 2)
t2
UPDATE account 2 → waits for B
t3
UPDATE account 1 → waits for A
t4
ERROR: deadlock detected
continues and commits

Let's cause one. Session A locks account one. Session B locks account two.

SQL - dl a1
BEGIN;
UPDATE accounts SET balance = balance - 50
WHERE id = 1;
SQL - dl b1 (session B)
BEGIN;
UPDATE accounts SET balance = balance - 70
WHERE id = 2;

A now updates account two, and hangs, waiting for B. B updates account one. That closes the circle. One second later, session A gets the error: deadlock detected. Its transaction is rolled back, and B's update goes through.

SQL - dl a2
UPDATE accounts SET balance = balance + 50
WHERE id = 2;
SQL - dl b2 (session B)
UPDATE accounts SET balance = balance + 70
WHERE id = 1;
Error (PostgreSQL 16)ERROR: deadlock detected DETAIL: Process 16965 waits for ShareLock on transaction 2094; blocked by process 16966. Process 16966 waits for ShareLock on transaction 2093; blocked by process 16965.

Result: UPDATE 1

The fix is in the application. Always lock rows in the same order, for example the smaller account ID first. Then the second transfer simply waits, and no cycle can form. Keep transactions short, and retry when you get this error, because the victim did nothing wrong.

Interview tip

Lock in a consistent order (lowest ID first), keep transactions short, and retry.

Question 5: Ten workers pull jobs from a jobs table. Two workers must never take the same job, and they should not wait for each other. How do you write the query that picks the next jobs?

Say this first

Select the next rows with FOR UPDATE SKIP LOCKED. FOR UPDATE locks the rows a worker takes, and SKIP LOCKED tells the other workers to skip rows that are already locked, instead of waiting for them.

Two workers ask for two jobs each, at the same moment. Worker A takes jobs one and two, and locks them. Worker B runs the same query. Jobs one and two are locked, so it skips them and takes three and four. Nobody waits, and no job is taken twice. Job five stays in the queue for the next worker.

Worker ALIMIT 2 FOR UPDATE SKIP LOCKED
jobs
job_idstatus
1queued
2queued
3queued
4queued
5queued
Worker Bskips 1 and 2, takes 3 and 4

Here is worker A. It begins a transaction and selects two queued jobs with FOR UPDATE SKIP LOCKED. It gets jobs one and two.

SQL - worker a
BEGIN;
SELECT job_id, payload
FROM jobs
WHERE status = 'queued'
ORDER BY job_id
LIMIT 2
FOR UPDATE SKIP LOCKED;
Output (PostgreSQL 16)2 rows
job_idpayload
1send invoice 1001
2send invoice 1002

Worker B runs the identical query while A's transaction is still open. It returns at once, with jobs three and four.

SQL - worker b (session B)
BEGIN;
SELECT job_id, payload
FROM jobs
WHERE status = 'queued'
ORDER BY job_id
LIMIT 2
FOR UPDATE SKIP LOCKED;
Output (PostgreSQL 16)2 rows
job_idpayload
3send invoice 1003
4send invoice 1004

Each worker then does the work, marks its jobs as done, and commits. If a worker crashes, its transaction rolls back, the locks disappear, and the jobs are free again. Without SKIP LOCKED, worker B would have waited for A, and your ten workers would run one at a time.

Interview tip

A crashed worker = rollback = its jobs return to the queue automatically.

Question 6: During a deployment, a simple ALTER TABLE ADD COLUMN froze the whole application for minutes. The ALTER itself is instant. What happened?

Say this first

ALTER TABLE needs an ACCESS EXCLUSIVE lock, which conflicts with everything, even a SELECT. One long open transaction on the table made the ALTER wait, and every new query then queued behind the ALTER. Always set a lock_timeout before DDL.

Three players. A session read the table inside a transaction and never committed. It holds a small lock, harmless by itself. The ALTER TABLE arrives. It needs the strongest lock, so it waits for that session. Now comes the real damage. Every new query on the table, even a plain SELECT, lines up behind the waiting ALTER. The application looks frozen.

time
Forgotten transaction
Deployment
Application
t1
BEGIN; SELECT ... (stays open)
t2
ALTER TABLE → waits
t3
SELECT → waits behind ALTER
t4
still idle in transaction
UPDATE → waits behind ALTER

Let's reproduce it safely. Session A reads the table in a transaction and leaves it open.

SQL - idle tx
BEGIN;
SELECT COUNT(*) FROM accounts;
Output (PostgreSQL 16)1 row
count
3

Session B is the deployment. First it sets a lock timeout of two seconds, then it runs the ALTER. After two seconds Postgres cancels the statement. The deployment fails, but the application never queued behind it.

SQL - alter timeout (session B)
SET lock_timeout = '2s';
ALTER TABLE accounts ADD COLUMN note TEXT;
Error (PostgreSQL 16)ERROR: canceling statement due to lock timeout

Once session A commits, the same ALTER takes a few milliseconds.

SQL - alter ok (session B)
ALTER TABLE accounts ADD COLUMN note TEXT;

Result: ALTER TABLE

So the rule for every migration: set lock_timeout, and retry in a loop. And protect the server from forgotten transactions with idle_in_transaction_session_timeout.

Interview tip

Migrations: SET lock_timeout + retry. Server: idle_in_transaction_session_timeout.

Question 7: Users report that updates on the accounts table hang. Nothing is in the error log. How do you find out which session is blocking the others?

Say this first

Look in pg_stat_activity for sessions that are waiting on a Lock, and ask pg_blocking_pids who blocks each one. The blocker is very often a session that is idle in transaction.

The picture is always the same. One session changed a row and has not committed. It is not running anything. It is idle in transaction. Another session wants the same row, so its query is active, but waiting on a lock. pg_blocking_pids takes the waiting process ID and returns the process ID of the session in its way.

Session AUPDATE done, no COMMIT
state: idle in transaction
holds the row lock
Session BUPDATE same row
state: active, waiting on Lock
pg_blocking_pids(pid of B)  →  { pid of A }

Set the scene. Session A updates account three and keeps its transaction open. Session B tries to update the same row, and hangs.

SQL - blocker
UPDATE accounts SET balance = balance + 1
WHERE id = 3;        -- inside BEGIN, no COMMIT yet
SQL - blocked (session B)
UPDATE accounts SET balance = 0
WHERE id = 3;

From a third session, we ask pg_stat_activity. Session B is active and waiting on a Lock, and blocked_by shows one process ID. That ID is session A, which is idle in transaction. There is the culprit.

SQL - who blocks (session C)
SELECT pid, state, wait_event_type,
       pg_blocking_pids(pid) AS blocked_by,
       left(query, 34) AS query
FROM pg_stat_activity
WHERE datname = current_database()
  AND pid <> pg_backend_pid()
ORDER BY pid;
Output (PostgreSQL 16)2 rows
pidstatewait_event_typeblocked_byquery
16965idle in transactionClient{}UPDATE accounts SET balance = bala
16966activeLock{16965}UPDATE accounts SET balance = 0 WH

Then you decide. Ask the owner to commit, or end that session with pg_terminate_backend and its process ID. And log lock waits, with log_lock_waits, so the next incident is in the log.

Interview tip

End the blocker: SELECT pg_terminate_backend(pid); Turn on log_lock_waits.

Key points to remember

  • MVCC: an UPDATE writes a new row version, so readers and writers do not block each other
  • Read Committed takes a new snapshot per statement, Repeatable Read keeps one per transaction
  • Avoid lost updates with an atomic UPDATE or SELECT ... FOR UPDATE
  • Deadlocks: lock rows in the same order, and retry the transaction Postgres aborts
  • Use SKIP LOCKED for queues, lock_timeout for DDL, and pg_blocking_pids to find the blocker

The full SQL script

Here is everything in one script: the sample data and every query from this post, in order.

Show the full SQL script (sample data and every query)
Full script - copy and run top to bottom
-- ========================================================================
-- PostgreSQL Interview Q&A #7 - Transactions, MVCC and locks
-- TechBrothersIT - run on PostgreSQL 16
-- ========================================================================

-- Sample data
CREATE TABLE accounts (id INT PRIMARY KEY, owner TEXT, balance INT);
INSERT INTO accounts VALUES (1, 'Sara', 1000), (2, 'Omar', 500), (3, 'Lina', 750);

CREATE TABLE jobs (job_id INT PRIMARY KEY, payload TEXT, status TEXT DEFAULT 'queued');
INSERT INTO jobs (job_id, payload) VALUES
  (1, 'send invoice 1001'), (2, 'send invoice 1002'), (3, 'send invoice 1003'),
  (4, 'send invoice 1004'), (5, 'send invoice 1005');

-- Step 1 (question 1): a update
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

-- Step 2 (question 1): b read  [run in session B]
SELECT ctid, xmin, xmax, balance
FROM accounts
WHERE id = 1;

-- Step 3 (question 1): a read
SELECT ctid, xmin, xmax, balance
FROM accounts
WHERE id = 1;

-- Step 4 (question 2): rc first
BEGIN;   -- READ COMMITTED is the default
SELECT SUM(balance) AS total FROM accounts;

-- Step 5 (question 2): rc other  [run in session B]
UPDATE accounts
SET balance = balance + 500
WHERE id = 2;

-- Step 6 (question 2): rc second
SELECT SUM(balance) AS total FROM accounts;

-- Step 7 (question 2): rr first
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(balance) AS total FROM accounts;

-- Step 8 (question 2): rr other  [run in session B]
UPDATE accounts
SET balance = balance + 500
WHERE id = 2;

-- Step 9 (question 2): rr second
SELECT SUM(balance) AS total FROM accounts;

-- Step 10 (question 3): lu read a
SELECT balance FROM accounts WHERE id = 1;

-- Step 11 (question 3): lu read b  [run in session B]
SELECT balance FROM accounts WHERE id = 1;

-- Step 12 (question 3): lu write a
UPDATE accounts SET balance = 900 WHERE id = 1;

-- Step 13 (question 3): lu write b  [run in session B]
UPDATE accounts SET balance = 800 WHERE id = 1;

-- Step 14 (question 3): lu final  [run in session B]
SELECT balance FROM accounts WHERE id = 1;

-- Step 15 (question 3): atomic a
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

-- Step 16 (question 3): atomic b  [run in session B]
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;

-- Step 17 (question 3): atomic final  [run in session B]
SELECT balance FROM accounts WHERE id = 1;

-- Step 18 (question 4): dl a1
BEGIN;
UPDATE accounts SET balance = balance - 50
WHERE id = 1;

-- Step 19 (question 4): dl b1  [run in session B]
BEGIN;
UPDATE accounts SET balance = balance - 70
WHERE id = 2;

-- Step 20 (question 4): dl a2
UPDATE accounts SET balance = balance + 50
WHERE id = 2;

-- Step 21 (question 4): dl b2  [run in session B]
UPDATE accounts SET balance = balance + 70
WHERE id = 1;

-- Step 22 (question 5): worker a
BEGIN;
SELECT job_id, payload
FROM jobs
WHERE status = 'queued'
ORDER BY job_id
LIMIT 2
FOR UPDATE SKIP LOCKED;

-- Step 23 (question 5): worker b  [run in session B]
BEGIN;
SELECT job_id, payload
FROM jobs
WHERE status = 'queued'
ORDER BY job_id
LIMIT 2
FOR UPDATE SKIP LOCKED;

-- Step 24 (question 6): idle tx
BEGIN;
SELECT COUNT(*) FROM accounts;

-- Step 25 (question 6): alter timeout  [run in session B]
SET lock_timeout = '2s';
ALTER TABLE accounts ADD COLUMN note TEXT;

-- Step 26 (question 6): alter ok  [run in session B]
ALTER TABLE accounts ADD COLUMN note TEXT;

-- Step 27 (question 7): blocker
UPDATE accounts SET balance = balance + 1
WHERE id = 3;        -- inside BEGIN, no COMMIT yet

-- Step 28 (question 7): blocked  [run in session B]
UPDATE accounts SET balance = 0
WHERE id = 3;

-- Step 29 (question 7): who blocks  [run in session C]
SELECT pid, state, wait_event_type,
       pg_blocking_pids(pid) AS blocked_by,
       left(query, 34) AS query
FROM pg_stat_activity
WHERE datname = current_database()
  AND pid <> pg_backend_pid()
ORDER BY pid;
Next in this series

Part 8: JSONB, arrays and Postgres types

The sample data on this page is invented for teaching. Every output table and query plan is the real result of the query above it, run on PostgreSQL 16 over that sample data.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.