Locks and Transactions10 min read

PostgreSQL Lock Manager Contention: When Too Many Tables Freeze Throughput

Every query takes lightweight relation locks you never think about. Cross the fast-path limit of 16 per backend, usually via heavy partitioning, and the shared lock manager turns into a bottleneck under concurrency.

A team migrated a big table to partitioning to make maintenance easier. They created a partition per day, two years of history, plus future partitions. About 800 partitions, all under one parent. Maintenance got easier. Then concurrency got worse.

Under load, a query that touched the partitioned table through the parent would wait on lock_manager. Not a row lock, not a table lock anyone took on purpose. A lightweight lock on PostgreSQL's internal lock machinery itself.

This is one of the least intuitive scaling limits in PostgreSQL, because the locks involved are ones you never write and rarely see, and the threshold that flips it from free to expensive is a small fixed number.

Every query locks relations

To read a table, PostgreSQL takes an ACCESS SHARE lock on it. This prevents someone from dropping the table out from under your query. You never type it; the planner and executor take these relation locks automatically, one per table and one per index involved.

For a simple query that is a couple of locks. For a query against a partitioned table, the planner may lock the parent and every partition it cannot prune, plus each of their indexes. The count adds up fast.

The fast path and its limit of 16

Taking a normal heavyweight lock means touching the shared lock table, which is protected by partitioned LWLocks. To avoid that cost for the common case, PostgreSQL has a fast path: a backend can record up to 16 weak relation locks in its own private memory without touching the shared structures.

Sixteen. That is the number. As long as a query needs 16 or fewer of these locks, it uses the cheap private path. The 17th lock spills over into the shared lock manager, and now you are contending with every other backend on the same partitioned LWLocks.

Why partitioning is the usual trigger

Partitioning is the most common way to blow past 16. A query that the planner cannot prune down to a handful of partitions will lock the parent plus many children plus their indexes. Each of those is a relation lock. Hundreds of partitions can mean dozens or hundreds of locks per query.

Now run that at OLTP concurrency. Every backend overflows the fast path, every backend hits the shared lock manager, and the LWLocks protecting it become the bottleneck. CPU looks busy but throughput is flat, and pg_stat_activity is full of lock_manager waits.

-- Is the lock manager the bottleneck right now?
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND wait_event_type = 'LWLock'
GROUP BY 1, 2
ORDER BY count(*) DESC;

-- How many relation locks is a given backend holding?
SELECT pid, count(*) AS relation_locks
FROM pg_locks
WHERE locktype = 'relation'
GROUP BY pid
ORDER BY relation_locks DESC
LIMIT 10;

Fix number one: prune harder

The best lock is the one you never take. If queries always filter on the partition key, the planner can prune to a single partition and lock only what it touches. The problem usually is not partitioning itself; it is queries that do not include the partition key in the WHERE clause and so cannot be pruned.

Make sure runtime pruning is actually happening. Check the plan: you want to see a small number of partitions in the Append node, not all of them. A query that scans the parent with no usable partition predicate is the one generating the lock storm.

-- Good: prunes to one partition, few locks
EXPLAIN SELECT * FROM events
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-02';

-- Bad: no partition key predicate, locks everything
EXPLAIN SELECT * FROM events WHERE user_id = 42;

Fix number two: fewer, bigger partitions

Daily partitions for two years is 730 partitions before you count the future. Monthly partitions for the same window is 24. If your access patterns and retention policy allow coarser granularity, you cut the worst-case lock count by an order of magnitude.

Resist the urge to partition pre-emptively. A table with tens of millions of rows often does not need partitioning at all; a good index serves it fine. Partition when you have a concrete reason (retention, maintenance windows, query pruning), and choose the coarsest granularity that still serves that reason.

Fix number three: reduce indexes per partition

Remember each index on each scanned partition is its own relation lock. Five indexes across 30 unpruned partitions is 150 locks before the heaps. Trimming redundant indexes lowers both write amplification and the per-query lock count. It is the same cleanup that helps writes, with a second payoff here.

The mental model to keep

Think of 16 as a budget per query, spent on every table and index the executor must lock. Partitioning, wide joins across many tables, and redundant indexes all draw down that budget. Stay under it and locking is effectively free. Blow past it at high concurrency and the shared lock manager, not your disks or CPU, decides your ceiling.

  • Count locks as relations + indexes the query cannot prune.
  • Filter on the partition key so pruning keeps the count low.
  • Prefer the coarsest partition granularity your policy allows.
  • Drop redundant indexes; each one is another lock per partition.
  • Watch lock_manager LWLock waits, not just CPU and IO.

The practical standard

The best PostgreSQL performance work is boring in the right way. Name the failure mode, capture the before plan or metric, make one change, and compare the exact same signal afterward. Anything else is just a more confident guess.