Transactions and Concurrency Control
Learn how to use transactions to maintain data consistency and handle concurrent database access safely
TLDR: Wrap related operations in transactions with BEGIN/COMMIT. Transactions guarantee all operations succeed together or all fail. PostgreSQL provides ACID guarantees. Use appropriate isolation levels to balance consistency and performance. Handle deadlocks gracefully.
Transactions ensure data stays consistent even when multiple operations happen simultaneously or failures occur.
What Are Transactions?
A transaction is a sequence of operations that execute as a single unit:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Either both updates happen or neither does. No partial transfers.
Basic Transaction Commands
-- Start transaction
BEGIN;
-- Do work
INSERT INTO users (username, email) VALUES ('alice', '[email protected]');
INSERT INTO posts (user_id, title) VALUES (1, 'First Post');
-- If everything worked:
COMMIT;
-- If something went wrong:
-- ROLLBACK;
Rollback on Error
BEGIN;
INSERT INTO users (username, email) VALUES ('bob', '[email protected]');
INSERT INTO users (username, email) VALUES ('bob', '[email protected]'); -- Fails (duplicate username)
ROLLBACK; -- Undo the first INSERT too
PostgreSQL automatically rolls back if an error occurs and you don't handle it.
ACID Properties
Transactions provide ACID guarantees:
Atomicity
All operations succeed or all fail. No partial execution.
BEGIN;
UPDATE inventory SET quantity = quantity - 5 WHERE product_id = 1;
INSERT INTO orders (product_id, quantity) VALUES (1, 5);
COMMIT;
If either statement fails, both roll back. Inventory and orders stay in sync.
Consistency
Database moves from one valid state to another. Constraints are enforced.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Total balance unchanged:
SELECT SUM(balance) FROM accounts; -- Same before and after
Isolation
Concurrent transactions don't interfere with each other.
-- Transaction 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- Sees 500
-- ... do something ...
COMMIT;
-- Transaction 2 (running simultaneously)
BEGIN;
UPDATE accounts SET balance = 1000 WHERE id = 1;
COMMIT;
-- Transaction 1 doesn't see Transaction 2's changes until it commits
Durability
Once committed, changes persist even if the system crashes.
BEGIN;
INSERT INTO critical_data (value) VALUES ('important');
COMMIT;
-- Power failure here
-- On restart, the insert is still there
PostgreSQL writes to the Write-Ahead Log (WAL) before confirming commits, ensuring durability.
Isolation Levels
Control how transactions see each other's changes. Trade-off between consistency and concurrency.
Read Uncommitted
Not supported in PostgreSQL (treated as Read Committed). Allows dirty reads in other databases.
Read Committed (Default)
Sees only committed changes. Most common level.
-- Transaction 1
BEGIN;
UPDATE products SET price = 100 WHERE id = 1;
-- Not committed yet
-- Transaction 2
BEGIN;
SELECT price FROM products WHERE id = 1; -- Sees old price
-- Wait for Transaction 1 to commit
SELECT price FROM products WHERE id = 1; -- Now sees 100
COMMIT;
Repeatable Read
Queries see a consistent snapshot from transaction start.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- Sees 500
-- Another transaction updates the balance to 1000 and commits
SELECT balance FROM accounts WHERE id = 1; -- Still sees 500 (snapshot)
COMMIT;
Prevents non-repeatable reads but can cause serialization failures:
-- Transaction 1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM products WHERE id = 1;
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- Transaction 2 modified this row
COMMIT; -- ERROR: could not serialize access
Serializable
Strongest isolation. Transactions execute as if they ran one after another.
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... operations ...
COMMIT;
Prevents all anomalies but causes more serialization failures. Use when consistency is critical.
Set default level:
ALTER DATABASE mydb SET default_transaction_isolation TO 'repeatable read';
Savepoints
Partial rollbacks within transactions:
BEGIN;
INSERT INTO users (username) VALUES ('alice');
SAVEPOINT before_posts;
INSERT INTO posts (user_id, title) VALUES (1, 'Post 1');
INSERT INTO posts (user_id, title) VALUES (1, 'Post 2'); -- Might fail
-- If post insert fails, rollback to savepoint
ROLLBACK TO before_posts;
-- User insert still committed
COMMIT;
Useful for complex operations where some parts can fail while others succeed.
Handling Concurrent Access
Row-Level Locking
SELECT FOR UPDATE locks rows:
BEGIN;
SELECT * FROM inventory WHERE product_id = 1 FOR UPDATE;
-- Other transactions wait here
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
COMMIT;
Prevents two transactions from decrementing the same inventory simultaneously.
Lock Types
-- Exclusive lock (blocks all other locks)
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- Shared lock (blocks FOR UPDATE but allows FOR SHARE)
SELECT * FROM products WHERE id = 1 FOR SHARE;
-- Skip locked rows
SELECT * FROM queue WHERE processed = false FOR UPDATE SKIP LOCKED LIMIT 10;
-- Error if rows are locked
SELECT * FROM products WHERE id = 1 FOR UPDATE NOWAIT;
FOR UPDATE SKIP LOCKED is great for job queues - each worker gets different rows.
Advisory Locks
Application-level locks using arbitrary numbers:
-- Try to acquire lock 1234
SELECT pg_try_advisory_lock(1234);
-- Do work
UPDATE shared_resource SET value = value + 1;
-- Release lock
SELECT pg_advisory_unlock(1234);
Useful for coordinating between applications.
Deadlocks
Two transactions wait for each other:
-- Transaction 1
BEGIN;
UPDATE products SET price = 100 WHERE id = 1;
-- Waiting for Transaction 2
-- Transaction 2
BEGIN;
UPDATE products SET price = 200 WHERE id = 2;
UPDATE products SET price = 150 WHERE id = 1; -- Waits for Transaction 1
-- Transaction 1 continues
UPDATE products SET price = 250 WHERE id = 2; -- Waits for Transaction 2
-- DEADLOCK
PostgreSQL detects deadlocks and aborts one transaction:
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5678.
Handle in application code:
try:
execute_transaction()
except DeadlockError:
retry_transaction()
Prevent deadlocks by accessing resources in consistent order:
-- Always update products in ID order
BEGIN;
UPDATE products SET price = 100 WHERE id IN (1, 2, 5) ORDER BY id;
COMMIT;
Long-Running Transactions
Avoid holding transactions open:
-- Bad
BEGIN;
SELECT * FROM products;
-- User thinks for 10 minutes
UPDATE products SET ...;
COMMIT;
Long transactions:
- Block other transactions
- Prevent vacuum from cleaning up dead rows
- Hold locks longer than necessary
Keep transactions short:
-- Good
SELECT * FROM products;
-- Let user decide
-- When ready:
BEGIN;
UPDATE products SET ...;
COMMIT;
Practical Transaction Patterns
Transfer Money
BEGIN;
UPDATE accounts SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
IF NOT FOUND THEN
ROLLBACK;
RAISE EXCEPTION 'Insufficient funds';
END IF;
UPDATE accounts SET balance = balance + 100
WHERE id = 2;
COMMIT;
Process Queue Items
BEGIN;
-- Lock and get next item
SELECT * FROM queue
WHERE processed = false
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- Process item
-- ... do work ...
-- Mark processed
UPDATE queue SET processed = true WHERE id = ...;
COMMIT;
Multiple workers can process different items concurrently.
Atomic Counters
BEGIN;
UPDATE statistics
SET view_count = view_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE page_id = 123;
COMMIT;
Transaction Best Practices
Keep transactions short: Acquire locks, do work, commit. Don't hold transactions during user input or external API calls.
Handle errors: Always have error handling that rolls back on failure.
Use appropriate isolation level: Read Committed for most cases, Repeatable Read or Serializable when needed.
Avoid nested transactions: PostgreSQL doesn't support true nested transactions. Use savepoints instead.
Retry serialization failures: When using Repeatable Read or Serializable, retry on conflicts.
Index foreign keys: Helps avoid lock waits during updates.
Monitor lock waits:
SELECT
pid,
usename,
pg_blocking_pids(pid) AS blocked_by,
query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
Transactions keep your data consistent even with concurrent access and failures. Next, we'll cover backup and recovery - how to protect your data and restore it when needed.
Found an issue?