Row-Level Locks & MVCC
How PostgreSQL coordinates concurrent access to the same rows. Two mechanisms: locks (explicit coordination) and MVCC (implicit versioning).
Row-Level Locks
When two transactions need to touch the same row, PostgreSQL uses row-level locks to coordinate. There are two flavors you need to know, and two more PostgreSQL-specific optimizations.
Shared vs Exclusive
| Lock Type | SQL | What It Means |
|---|---|---|
| Shared | SELECT ... FOR SHARE |
"I'm reading this row -- don't delete or modify it, but others can read too" |
| Exclusive | SELECT ... FOR UPDATE |
"I'm about to modify this row -- nobody else can touch it" |
PostgreSQL also has two finer-grained variants:
| Lock Type | SQL | Use Case |
|---|---|---|
| FOR NO KEY UPDATE | SELECT ... FOR NO KEY UPDATE |
Like FOR UPDATE, but allows concurrent FOR KEY SHARE (useful when you're updating non-key columns) |
| FOR KEY SHARE | SELECT ... FOR KEY SHARE |
Like FOR SHARE, but only protects the key columns from being changed (used internally by foreign key checks) |
The Compatibility Matrix
This is the core mental model. "Can two transactions hold these locks on the same row at the same time?"
| Requested \ Held | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | Yes | Yes | Yes | No |
| FOR SHARE | Yes | Yes | No | No |
| FOR NO KEY UPDATE | Yes | No | No | No |
| FOR UPDATE | No | No | No | No |
The pattern: FOR UPDATE conflicts with everything (including itself). FOR KEY SHARE is the most permissive. As you go from top-left to bottom-right, compatibility shrinks.
graph LR
KS["FOR KEY SHARE<br/>(most permissive)"] --> SH["FOR SHARE"]
SH --> NKU["FOR NO KEY UPDATE"]
NKU --> UP["FOR UPDATE<br/>(most restrictive)"]
style KS fill:#1a3a1a,stroke:#66ff66
style SH fill:#2a3a1a,stroke:#aaff44
style NKU fill:#4a3a1a,stroke:#ffaa44
style UP fill:#4a1a1a,stroke:#ff6666
Implicit vs Explicit Locks
Not every statement requires you to write FOR UPDATE. PostgreSQL takes locks
automatically for data-modifying statements:
| Statement | Lock Taken | You Write It? |
|---|---|---|
SELECT (plain) |
No lock at all (uses MVCC -- see below) | -- |
SELECT ... FOR SHARE |
Shared (row-level) | Explicit |
SELECT ... FOR UPDATE |
Exclusive (row-level) | Explicit |
UPDATE |
Exclusive (row-level) | Implicit -- PostgreSQL does it for you |
DELETE |
Exclusive (row-level) | Implicit -- PostgreSQL does it for you |
Key takeaway: UPDATE and DELETE automatically take exclusive row locks.
You don't need SELECT ... FOR UPDATE followed by UPDATE -- the UPDATE itself
locks the row. You use FOR UPDATE when you need to lock the row before deciding
what to do (read-then-write pattern).
Example: FOR SHARE -- Protecting a Foreign Key Reference
Scenario: You're inserting an order for customer 42. You want to make sure the customer doesn't get deleted between your check and your insert.
-- Transaction A: inserting an order
BEGIN;
SELECT * FROM customers WHERE id = 42 FOR SHARE;
-- Customer 42 is now locked with a shared lock.
-- Nobody can DELETE or UPDATE it, but other transactions CAN also FOR SHARE it.
INSERT INTO orders (customer_id, amount) VALUES (42, 99.99);
COMMIT;
-- Transaction B (concurrent): also inserting an order for same customer
BEGIN;
SELECT * FROM customers WHERE id = 42 FOR SHARE;
-- This succeeds! Multiple shared locks can coexist.
INSERT INTO orders (customer_id, amount) VALUES (42, 50.00);
COMMIT;
-- Transaction C (concurrent): trying to delete that customer
BEGIN;
DELETE FROM customers WHERE id = 42;
-- BLOCKS here! DELETE needs an exclusive lock, but A and B hold shared locks.
-- C waits until both A and B commit.
sequenceDiagram
participant A as Txn A (Insert Order)
participant DB as PostgreSQL
participant B as Txn B (Insert Order)
participant C as Txn C (Delete Customer)
A->>DB: BEGIN
A->>DB: SELECT * FROM customers<br/>WHERE id=42 FOR SHARE
DB-->>A: Row locked (SHARE)
B->>DB: BEGIN
B->>DB: SELECT * FROM customers<br/>WHERE id=42 FOR SHARE
DB-->>B: Row locked (SHARE) -- OK!<br/>Shared + Shared = compatible
C->>DB: BEGIN
C->>DB: DELETE FROM customers WHERE id=42
Note over C,DB: BLOCKS! DELETE needs<br/>exclusive lock, but A and B<br/>hold shared locks
A->>DB: INSERT INTO orders ...
A->>DB: COMMIT
Note over A,DB: A's shared lock released
B->>DB: INSERT INTO orders ...
B->>DB: COMMIT
Note over B,DB: B's shared lock released
DB-->>C: DELETE proceeds now<br/>(but orders exist, so FK might block it anyway)
Example: FOR UPDATE -- Bank Balance Debit
Scenario: Two ATMs try to debit the same account at the same time. Without locking, both read $1000, both debit $800, both write $200. The bank loses $800.
-- Transaction A: ATM debit
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Returns $1000. Row is now exclusively locked.
-- Nobody else can read FOR UPDATE or modify this row until we're done.
UPDATE accounts SET balance = balance - 800 WHERE id = 1;
COMMIT;
-- Transaction B: concurrent ATM debit (arrives moments later)
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- BLOCKS here until A commits.
-- After A commits, B sees $200 (the updated balance).
-- B can then decide: is $200 enough for the requested debit?
Without FOR UPDATE, both transactions would read $1000 and both would succeed --
a classic lost update.
MVCC (Multi-Version Concurrency Control)
This is the reason plain SELECT doesn't take any locks and never blocks.
The Core Idea
Instead of locking rows so readers wait for writers, PostgreSQL keeps multiple versions of each row. Think of it like Kafka's append-only log:
UPDATEdoesn't overwrite the old row. It creates a new version and marks the old one as expired.SELECTreads whichever version was valid at the "right point in time" (depends on isolation level -- see 02-isolation-levels.md).- Old versions are cleaned up later by
VACUUM.
graph LR
subgraph "Row versions for account id=1"
V1["Version 1<br/>balance = 1000<br/>created: T100<br/>expired: T105"]
V2["Version 2<br/>balance = 200<br/>created: T105<br/>expired: --"]
end
A["Txn A (started T103)<br/>SELECT balance ..."] -.->|"Sees V1<br/>(was valid at T103)"| V1
B["Txn B (started T110)<br/>SELECT balance ..."] -.->|"Sees V2<br/>(current version)"| V2
VACUUM["VACUUM<br/>(eventually)"] -->|"Cleans up V1<br/>when no txn needs it"| V1
style V1 fill:#4a3a1a,stroke:#ffaa44
style V2 fill:#1a3a1a,stroke:#66ff66
Why This Matters
- Readers never block writers. Your
SELECTdoesn't stop anUPDATEfrom proceeding. - Writers never block readers. An ongoing
UPDATEdoesn't make yourSELECTwait. - The only thing that blocks is writer vs writer -- two transactions trying to
UPDATEthe same row. The second one waits for the first to commit or rollback.
This is fundamentally different from MySQL's default (InnoDB), where reads can take shared locks under certain isolation levels.
Table-Level Locks (Brief Mention)
These exist but are rarely something you'd use directly in application code.
| Lock | When It's Used |
|---|---|
ACCESS SHARE |
Taken automatically by SELECT -- prevents the table from being dropped while you're reading |
ACCESS EXCLUSIVE |
Taken by ALTER TABLE, DROP TABLE -- blocks everything |
SHARE |
Sometimes used for schema migrations that need a consistent snapshot |
For interviews: you should know table-level locks exist and are used for DDL operations (schema changes, migrations). You don't need to memorize the full matrix. The row-level stuff above is what matters for system design discussions.
Advisory Locks
Advisory locks don't lock any row or table. They lock an arbitrary number that your application defines. PostgreSQL just provides the coordination -- your app decides what the number means.
-- Acquire advisory lock on number 42
SELECT pg_advisory_lock(42);
-- ... do work ...
-- Release it
SELECT pg_advisory_unlock(42);
Why Would You Lock a Number?
Think about a distributed cron job. You have 10 servers, each running a scheduler. Every minute, all 10 try to run the "send daily email digest" job. Only one should run it.
-- Each server tries this:
SELECT pg_try_advisory_lock(hashtext('daily-email-digest'));
-- Returns true → you got the lock, run the job
-- Returns false → another server is running it, skip
sequenceDiagram
participant S1 as Server 1
participant DB as PostgreSQL
participant S2 as Server 2
participant S3 as Server 3
S1->>DB: pg_try_advisory_lock(123)
DB-->>S1: true ✅ (got lock)
S2->>DB: pg_try_advisory_lock(123)
DB-->>S2: false ❌ (already held)
S3->>DB: pg_try_advisory_lock(123)
DB-->>S3: false ❌ (already held)
Note over S1: Runs the job...
S1->>DB: pg_advisory_unlock(123)
Note over DB: Lock released
Two Flavors
| Function | Behavior | Use Case |
|---|---|---|
pg_advisory_lock(id) |
Waits until lock is available | Sequential processing |
pg_try_advisory_lock(id) |
Returns true/false immediately | Skip if someone else is doing it |
Session vs Transaction Scoped
| Scope | Functions | Released When |
|---|---|---|
| Session (default) | pg_advisory_lock / pg_advisory_unlock |
Explicit unlock or disconnect |
| Transaction | pg_advisory_xact_lock |
Automatically on COMMIT/ROLLBACK |
Advisory Lock vs Row Lock vs Application Lock
| Row Lock | Advisory Lock | App-Level Lock (Redis) | |
|---|---|---|---|
| What's locked | A specific row | An arbitrary number | A key in Redis |
| Scope | Within a transaction | Session or transaction | Across any system |
| Needs a row? | Yes | No | No |
| Needs PostgreSQL? | Yes | Yes | No (needs Redis) |
| Use case | Protect data | Coordinate processes | Distributed locking |
Further reading: - PostgreSQL docs: Advisory Locks - PostgreSQL docs: Advisory Lock Functions