Advanced PostgreSQL: Locks, Execution Plans and Partitioning
Comprehensive guide to PostgreSQL locks, execution plans, and partitioning for senior developers
If you already understand PostgreSQL basics, Advanced PostgreSQL can be understood through three major areas:
- Locks & concurrency — how does PostgreSQL handle when multiple transactions access data simultaneously?
- Execution plan — how does PostgreSQL decide to run SQL queries, and how do you know where queries are slow?
- Partitioning — how to split a very large table into multiple parts for more efficient querying/management?
1. Locks — How does PostgreSQL lock data?#
Imagine two transactions:
-- Transaction A
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;sqlWhile A hasn’t COMMIT yet, transaction B runs:
BEGIN;
UPDATE accounts
SET balance = balance + 50
WHERE id = 1;sqlB will have to wait for A to finish.
This is a form of row-level locking.
SELECT ... FOR UPDATE#
A very common pattern:
BEGIN;
SELECT *
FROM accounts
WHERE id = 1
FOR UPDATE;
-- process logic
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
COMMIT;sqlFOR UPDATE tells PostgreSQL:
“I’m preparing to modify this row, don’t let other transactions modify it simultaneously.”
Very useful when you have logic like:
read → check → changetextExample:
SELECT stock
FROM products
WHERE id = 10
FOR UPDATE;sqlThen:
UPDATE products
SET stock = stock - 1
WHERE id = 10;sqlIf you don’t lock correctly, two simultaneous requests might both see:
stock = 1textand both think the product is still in stock.
Lock types to know#
You don’t need to memorize all of them right away. The most important:
Row lock#
SELECT ...
FOR UPDATE;sqlor:
SELECT ...
FOR SHARE;sqlUse when concurrency is at the record level.
Table lock#
Example:
LOCK TABLE accounts;sqlLock at the table level.
Typically, application code should not casually use table locks, as they easily reduce concurrency.
Deadlock#
This is a very important part.
Transaction A:
lock row 1
↓
wait for row 2textTransaction B:
lock row 2
↓
wait for row 1textWe have:
A ──locks──> Row 1
A ──waits──> Row 2
B ──locks──> Row 2
B ──waits──> Row 1textNo one can proceed.
PostgreSQL will detect the deadlock and terminate one transaction.
How to prevent#
Ensure all transactions acquire locks in the same order.
For example, always:
lock smaller account first
→ lock larger accounttextinstead of this transaction:
account 1 → account 2textand that transaction:
account 2 → account 1text2. Execution Plan — How does PostgreSQL actually run queries?#
This is an extremely important skill when optimizing PostgreSQL.
You have a query:
SELECT *
FROM users
WHERE email = 'alice@example.com';sqlDon’t just ask:
“Is there an index?”
Ask:
How is PostgreSQL actually executing this query?
Use:
EXPLAIN
SELECT *
FROM users
WHERE email = 'alice@example.com';sqlFor example, PostgreSQL might return:
Index Scan using users_email_idx on users
Index Cond: (email = 'alice@example.com')textThat means PostgreSQL is using an index.
EXPLAIN ANALYZE#
More importantly:
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'alice@example.com';sqlEXPLAIN gives you the planned execution.
EXPLAIN ANALYZE will execute the query and measure actual performance.
Example:
Index Scan using users_email_idx on users
(cost=0.42..8.44 rows=1 width=100)
(actual time=0.035..0.037 rows=1 loops=1)textYou should focus on:
costrowsactual timeloops- scan type
Sequential Scan vs Index Scan#
Suppose the table has:
10,000,000 userstextQuery:
SELECT *
FROM users
WHERE id = 123;sqlPostgreSQL might use:
Index Scantextbecause it only needs to find one row.
But query:
SELECT *
FROM users
WHERE country = 'Vietnam';sqlIf 70% of the table is Vietnam, PostgreSQL might choose:
Seq Scantexteven if country has an index.
This is not necessarily a problem.
A common mistake:
“Seq Scan = bad query.”
Not true.
If you need to read most of the table, sequential reading is sometimes cheaper than using an index.
Execution nodes to know#
At minimum, understand:
Seq Scan
Index Scan
Index Only Scan
Bitmap Index Scan
Nested Loop
Hash Join
Merge Join
Sort
AggregatetextExample:
SELECT *
FROM orders o
JOIN users u ON u.id = o.user_id;sqlPostgreSQL might choose:
Nested Looptextor:
Hash Jointextor:
Merge JointextDepending on data, statistics, and cost model.
Query optimization example#
Suppose:
SELECT *
FROM orders
WHERE customer_id = 100
AND created_at >= '2026-01-01';sqlIndex:
CREATE INDEX idx_orders_customer
ON orders(customer_id);sqlIt might be better if your workload frequently queries both conditions:
CREATE INDEX idx_orders_customer_created
ON orders(customer_id, created_at);sqlThen:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE customer_id = 100
AND created_at >= '2026-01-01';sqlYou want to see if the planner utilizes the new index.
3. Partitioning — Splitting large tables#
Suppose:
orderstexthas:
2 billion rowstextand the data has:
created_attextYou can partition by month:
orders
├── orders_2026_01
├── orders_2026_02
├── orders_2026_03
├── ...
└── orders_2026_08textInstead of one massive table.
Range partitioning#
Example:
CREATE TABLE orders (
id bigint,
created_at timestamp,
customer_id bigint,
amount numeric
) PARTITION BY RANGE (created_at);sqlCreate partition:
CREATE TABLE orders_2026_08
PARTITION OF orders
FOR VALUES FROM ('2026-08-01')
TO ('2026-09-01');sqlNext month’s partition:
CREATE TABLE orders_2026_09
PARTITION OF orders
FOR VALUES FROM ('2026-09-01')
TO ('2026-10-01');sqlPartition pruning#
This is why partitioning is useful.
Query:
SELECT *
FROM orders
WHERE created_at >= '2026-08-01'
AND created_at < '2026-09-01';sqlPostgreSQL might only read:
orders_2026_08textinstead of:
orders_2026_01
orders_2026_02
orders_2026_03
...
orders_2026_08textThis is called partition pruning.
But partitioning is not a “magic performance button”#
Example:
SELECT *
FROM orders
WHERE customer_id = 123;sqlIf you partition by:
created_attextthen PostgreSQL might still have to search through multiple partitions.
Therefore, the partition key must match your workload.
An important question before partitioning:
“Which column do my largest queries typically filter by?”
4. How do these three topics relate?#
This is the important part.
Imagine an e-commerce system:
orders: 2 billion rowstextYou have:
SELECT *
FROM orders
WHERE customer_id = 123
AND created_at >= '2026-01-01';sqlYou might need:
Partitioning#
Split:
orders
→ by created_attextIndex#
Within each partition:
(customer_id, created_at)sqlExecution plan#
Check:
EXPLAIN ANALYZEsqlto see if PostgreSQL has:
Partition pruning
↓
Index Scan
↓
few rowstextor not.
5. Locks + Execution plan also relate#
A slow query doesn’t just annoy users.
Example:
Transaction A
↓
UPDATE
↓
holds lock
↓
query runs 30 secondstextMeanwhile:
Transaction B
↓
UPDATE same row
↓
WAITtextAn unoptimized query can cause locks to be held longer, creating:
slow query
↓
long transaction
↓
lock contention
↓
more waiting
↓
more slow requeststextThis is why when debugging production PostgreSQL, you shouldn’t just look at:
“This query takes 5 seconds.”
But also ask:
“What lock is it holding during those 5 seconds?”
6. A roadmap for learning Advanced PostgreSQL#
If your goal is senior/backend engineer, I would learn in this order:
flowchart TD
A[PostgreSQL] --> B[Transactions]
A --> C[Query Planning]
A --> D[Storage]
B --> E[Isolation]
B --> F[Locks]
B --> G[Deadlock]
C --> H[EXPLAIN]
C --> I[Indexes]
D --> J[MVCC]
D --> K[VACUUM]
D --> L[Bloat]
F --> M[Concurrency]
I --> N[Performance]
M --> O[Partitioning]
N --> O
O --> P[Large datasets]
After the three topics you asked about, the next topics very worth learning are:
- MVCC
- VACUUM / autovacuum
- Index internals — B-tree, GIN, GiST, BRIN
- Transaction isolation
- Deadlock debugging
- CTE / materialized CTE
- Window functions
- Query planner & statistics
- Table/index bloat
- Connection pooling
- Replication
- Read replicas
- Partition maintenance
If learning in a practical way, the trio EXPLAIN ANALYZE + locks + MVCC should be prioritized first, as they help you debug most PostgreSQL production issues.