PostgreSQL Lock Manager Explained: What Happens When Transactions Conflict

Picture a busy coffee shop. The barista can serve ten customers at once if they all just want to read the menu. But the moment someone walks in to rearrange the furniture, everyone has to step back. That is not a random policy — it is a deliberate trade-off between letting things flow smoothly most of the time and protecting the room when something structural needs to change.

PostgreSQL's lock manager works on exactly the same logic. And once you understand how it actually works under the hood, a whole class of production mysteries — queries that freeze for no apparent reason, deadlocks that appear at the worst possible moment, ALTER TABLE commands that bring a busy system to a halt — suddenly make complete sense.

There Are Eight Flavours of Lock, Not Just One

Most developers think of locks in binary terms: something is locked, or it is not. PostgreSQL thinks in eight levels, each designed for a specific kind of work.

A plain SELECT takes the weakest lock, called Access Share. It is so light that thousands of them can coexist on the same table simultaneously without any of them knowing the others exist. An INSERT, UPDATE, or DELETE takes a Row Exclusive lock — stronger, but still compatible with readers. CREATE INDEX takes a Share lock that blocks writers but leaves readers alone. And at the very top, ALTER TABLE or DROP TABLE takes an Access Exclusive lock that conflicts with absolutely everything, including those innocent SELECT statements.

These eight modes form a conflict table baked into the PostgreSQL source. Each mode carries a bitmask of every other mode it refuses to share space with. The entire rulebook fits in about twenty lines of code inside a lock, and it governs every concurrency decision the database makes from the moment it starts up.

How a Lock Actually Gets Stored

When your transaction acquires a lock, three things get created or updated behind the scenes.

The first is a shared LOCK object in shared memory — one per lockable thing, whether that thing is a table, a transaction ID, or a custom advisory identifier. This object keeps score: how many processes currently hold each lock mode, how many are waiting, and a queue of everyone who is blocked.

The second is a PROCLOCK object that connects a specific backend process to a specific lock. Think of it as the receipt — it records exactly what this process is holding.

The third is a private entry in the backend's own memory that acts as a shortcut. If your transaction asks for the same lock a second time, PostgreSQL finds the private entry instantly and skips the shared memory lookup entirely.

To prevent all these shared structures from becoming a bottleneck under heavy load, the lock table is split into partitions — typically sixteen or thirty-two of them. Each partition has its own lightweight lock protecting it. When a backend needs to look up a lock, it hashes the lock's identity, goes straight to the right partition, and only touches that slice. Thousands of backends can operate simultaneously without ever getting in each other's way.

The Fast Lane That Most Locks Take

Here is something most people do not know. For the common case — a SELECT or a simple INSERT against an ordinary table — PostgreSQL does not even touch that shared hash table. Instead, it writes the lock directly into a small private array inside the backend's own process structure. This is called the fast path, and it means a typical read lock is just a single array write with no shared memory contention at all.

The fast path works as long as nobody is holding a strong lock on the same table. The moment someone arrives with an ALTER TABLE or a CREATE INDEX, it scans all backends' private arrays, pulls any relevant locks into the main shared table, and from that point forces everyone through the normal path. Once the strong lock is gone, the fast path becomes available again. On a system with many readers and infrequent schema changes, the vast majority of lock operations never touch shared memory at all.

When a Lock Has to Wait

Sometimes a lock cannot be granted immediately because something conflicting is already held. When that happens, the waiting process is added to a queue on the LOCK object and put to sleep on its own semaphore — a small OS-level signal that lets it rest without burning CPU.

The queue is mostly first-in, first-out, which is the fair thing to do. But there is one deliberate exception. If a new arrival's lock request would conflict with a request that is already waiting ahead of it, it gets inserted in front of that waiting request. This prevents a subtle problem where a chain of compatible requests could silently form a deadlock through their queue ordering alone.

When a lock is released, PostgreSQL walks the wait queue and wakes up every process that can now be granted what it asked for. The process does not need to do anything — it simply wakes up, finds its lock already granted, and continues.

Why ALTER TABLE Freezes Everything After It

This is one of the most confusing things that happens in production, and the lock queue explains it perfectly.

An ALTER TABLE arrives and asks for an Access Exclusive lock. There are ten ongoing SELECT statements holding Access Share locks. The ALTER TABLE cannot proceed, so it joins the wait queue.

Now a new SELECT arrives. It would not conflict with the existing SELECT statements at all. But it sees that an ALTER TABLE is waiting in the queue, and out of fairness, it queues behind the ALTER TABLE. Then another SELECT arrives and queues behind that one. The table effectively locks up for reads, not because of a conflict, but because the queue is protecting the DDL operation from being starved forever by a continuous stream of readers.

Once the original ten SELECT statements finish, the ALTER TABLE runs, and then all the waiting reads are released simultaneously.

Deadlocks: A Cycle Nobody Planned

Deadlocks happen when two transactions each hold something the other needs. Transaction A locks row 1 and wants row 2. Transaction B locks row 2 and wants row 1. Neither can proceed. They will wait forever unless someone intervenes.

PostgreSQL detects this by setting a timer the moment a transaction starts waiting. After one second — configurable — it builds a wait-for graph and does a depth-first search looking for cycles. If it finds one, it looks at what kind of blockage is involved.

Some blocks are hard: one transaction is actually holding a conflicting lock that the other needs. There is no way around it — someone has to die. The transaction that ran the deadlock check is the one that gets cancelled, not for any deep reason, but because it keeps the implementation simple, and any participant in a true deadlock has to be rolled back anyway.

Other blocks are soft: a transaction is blocked only because of its position in a wait queue relative to another waiting transaction. These can often be resolved by reordering the queue — breaking the cycle without aborting anyone. PostgreSQL tries this first before escalating to a cancellation.

The Simple Mental Model

You do not need to hold all of this in your head at once. The useful takeaway is this: PostgreSQL locks are not a binary on/off switch but a spectrum of eight modes, and most of the time the fast path means they cost almost nothing. Conflicts do not just block — they queue, and that queue protects fairness at the cost of occasionally freezing more work than you expected. And deadlocks are not random failures but detectable cycles in a graph, resolved in under a second by a search algorithm that has been quietly running inside every PostgreSQL installation for decades.

whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message