PostgreSQL Transactions & Concurrency Best Practices¶
Objective: Master senior-level PostgreSQL transaction and concurrency patterns for production systems. When you need to handle concurrent access, when you want to optimize transaction performance, when you need enterprise-grade concurrency strategies—these best practices become your weapon of choice.
Core Principles¶
- ACID Properties: Ensure atomicity, consistency, isolation, and durability
- Isolation Levels: Choose appropriate isolation levels for your use case
- Lock Management: Minimize lock contention and deadlocks
- Performance: Optimize transaction performance and throughput
- Error Handling: Implement robust transaction error handling
Transaction Isolation Levels¶
Isolation Level Overview¶
-- Set transaction isolation levels
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Your transaction code here
COMMIT;
-- Different isolation levels for different use cases
BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- Lowest isolation
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; -- Default level
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- Higher isolation
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- Highest isolation
-- Check current isolation level
SELECT current_setting('transaction_isolation');
Isolation Level Examples¶
-- Create test tables for isolation level demonstration
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
account_number VARCHAR(20) UNIQUE NOT NULL,
balance NUMERIC(10,2) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
from_account_id INTEGER REFERENCES accounts(id),
to_account_id INTEGER REFERENCES accounts(id),
amount NUMERIC(10,2) NOT NULL,
transaction_type VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample data
INSERT INTO accounts (account_number, balance) VALUES
('ACC001', 1000.00),
('ACC002', 500.00),
('ACC003', 2000.00);
-- READ COMMITTED example
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE account_number = 'ACC001';
-- Another transaction can modify this data here
SELECT balance FROM accounts WHERE account_number = 'ACC001';
COMMIT;
-- REPEATABLE READ example
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE account_number = 'ACC001';
-- This will see the same data even if another transaction modifies it
SELECT balance FROM accounts WHERE account_number = 'ACC001';
COMMIT;
-- SERIALIZABLE example
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE account_number = 'ACC001';
-- This will fail if another transaction modifies the data
UPDATE accounts SET balance = balance - 100 WHERE account_number = 'ACC001';
COMMIT;
Lock Management¶
Row-Level Locking¶
-- Create tables for lock demonstration
CREATE TABLE inventory (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
reserved_quantity INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
total_amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(10,2) NOT NULL
);
-- Insert sample data
INSERT INTO inventory (product_id, quantity) VALUES
(1, 100),
(2, 50),
(3, 200);
-- Row-level locking examples
BEGIN;
-- Lock specific rows for update
SELECT * FROM inventory WHERE product_id = 1 FOR UPDATE;
-- Perform operations on locked rows
UPDATE inventory SET reserved_quantity = reserved_quantity + 10 WHERE product_id = 1;
COMMIT;
-- Lock for share (read lock)
BEGIN;
SELECT * FROM inventory WHERE product_id = 1 FOR SHARE;
-- Other transactions can read but not modify
COMMIT;
Table-Level Locking¶
-- Table-level locking examples
BEGIN;
-- Lock table for exclusive access
LOCK TABLE inventory IN EXCLUSIVE MODE;
-- Perform bulk operations
UPDATE inventory SET quantity = quantity - 10;
COMMIT;
-- Lock table for share access
BEGIN;
LOCK TABLE inventory IN SHARE MODE;
-- Allow other transactions to read but not modify
SELECT * FROM inventory;
COMMIT;
Deadlock Prevention¶
-- Create function to prevent deadlocks
CREATE OR REPLACE FUNCTION transfer_inventory(
from_product_id INTEGER,
to_product_id INTEGER,
quantity INTEGER
)
RETURNS BOOLEAN AS $$
DECLARE
from_quantity INTEGER;
to_quantity INTEGER;
BEGIN
-- Always lock in the same order to prevent deadlocks
IF from_product_id < to_product_id THEN
-- Lock from_product first
SELECT quantity INTO from_quantity
FROM inventory WHERE product_id = from_product_id FOR UPDATE;
SELECT quantity INTO to_quantity
FROM inventory WHERE product_id = to_product_id FOR UPDATE;
ELSE
-- Lock to_product first
SELECT quantity INTO to_quantity
FROM inventory WHERE product_id = to_product_id FOR UPDATE;
SELECT quantity INTO from_quantity
FROM inventory WHERE product_id = from_product_id FOR UPDATE;
END IF;
-- Check if sufficient inventory
IF from_quantity < quantity THEN
RAISE EXCEPTION 'Insufficient inventory: % available, % requested', from_quantity, quantity;
END IF;
-- Perform transfer
UPDATE inventory SET quantity = quantity - quantity WHERE product_id = from_product_id;
UPDATE inventory SET quantity = quantity + quantity WHERE product_id = to_product_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
Transaction Patterns¶
Savepoints and Nested Transactions¶
-- Create function with savepoints
CREATE OR REPLACE FUNCTION process_order(
customer_id INTEGER,
order_items JSONB
)
RETURNS INTEGER AS $$
DECLARE
order_id INTEGER;
item JSONB;
product_id INTEGER;
quantity INTEGER;
unit_price NUMERIC(10,2);
total_amount NUMERIC(10,2) := 0;
BEGIN
-- Start transaction
BEGIN
-- Create order
INSERT INTO orders (customer_id, total_amount)
VALUES (customer_id, 0) RETURNING id INTO order_id;
-- Process each order item
FOR item IN SELECT * FROM jsonb_array_elements(order_items) LOOP
product_id := (item->>'product_id')::INTEGER;
quantity := (item->>'quantity')::INTEGER;
unit_price := (item->>'unit_price')::NUMERIC(10,2);
-- Use savepoint for each item
SAVEPOINT item_processing;
BEGIN
-- Check inventory
IF NOT check_inventory_availability(product_id, quantity) THEN
RAISE EXCEPTION 'Insufficient inventory for product %', product_id;
END IF;
-- Reserve inventory
UPDATE inventory
SET reserved_quantity = reserved_quantity + quantity
WHERE product_id = product_id;
-- Add order item
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (order_id, product_id, quantity, unit_price);
total_amount := total_amount + (quantity * unit_price);
EXCEPTION
WHEN OTHERS THEN
-- Rollback to savepoint
ROLLBACK TO SAVEPOINT item_processing;
RAISE;
END;
END LOOP;
-- Update order total
UPDATE orders SET total_amount = total_amount WHERE id = order_id;
RETURN order_id;
EXCEPTION
WHEN OTHERS THEN
-- Rollback entire transaction
ROLLBACK;
RAISE;
END;
END;
$$ LANGUAGE plpgsql;
Optimistic Locking¶
-- Create table with version column for optimistic locking
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price NUMERIC(10,2) NOT NULL,
version INTEGER DEFAULT 1,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Create function for optimistic locking update
CREATE OR REPLACE FUNCTION update_product_optimistic(
product_id INTEGER,
new_name VARCHAR(200),
new_price NUMERIC(10,2),
expected_version INTEGER
)
RETURNS BOOLEAN AS $$
DECLARE
current_version INTEGER;
rows_affected INTEGER;
BEGIN
-- Update with version check
UPDATE products
SET name = new_name,
price = new_price,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = product_id AND version = expected_version;
GET DIAGNOSTICS rows_affected = ROW_COUNT;
IF rows_affected = 0 THEN
RAISE EXCEPTION 'Product was modified by another transaction. Expected version: %, current version: %',
expected_version, (SELECT version FROM products WHERE id = product_id);
END IF;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
Pessimistic Locking¶
-- Create function for pessimistic locking
CREATE OR REPLACE FUNCTION update_product_pessimistic(
product_id INTEGER,
new_name VARCHAR(200),
new_price NUMERIC(10,2)
)
RETURNS BOOLEAN AS $$
DECLARE
current_product RECORD;
BEGIN
-- Lock the row for update
SELECT * INTO current_product
FROM products
WHERE id = product_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Product not found: %', product_id;
END IF;
-- Update the product
UPDATE products
SET name = new_name,
price = new_price,
updated_at = CURRENT_TIMESTAMP
WHERE id = product_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
Concurrency Control¶
MVCC (Multi-Version Concurrency Control)¶
-- Create table to demonstrate MVCC
CREATE TABLE user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
session_token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- MVCC example: Multiple transactions can read without blocking
BEGIN;
-- Transaction 1: Read sessions
SELECT * FROM user_sessions WHERE is_active = TRUE;
-- This won't block other transactions
-- Transaction 2: Insert new session (runs concurrently)
INSERT INTO user_sessions (user_id, session_token, expires_at)
VALUES (1, 'token123', CURRENT_TIMESTAMP + INTERVAL '1 hour');
COMMIT;
Snapshot Isolation¶
-- Create function to demonstrate snapshot isolation
CREATE OR REPLACE FUNCTION get_user_balance_snapshot(user_id INTEGER)
RETURNS NUMERIC(10,2) AS $$
DECLARE
balance NUMERIC(10,2);
BEGIN
-- This will see a consistent snapshot of the data
SELECT COALESCE(SUM(amount), 0) INTO balance
FROM transactions
WHERE user_id = user_id;
RETURN balance;
END;
$$ LANGUAGE plpgsql;
Transaction Monitoring¶
Transaction Performance Analysis¶
# monitoring/transaction_monitor.py
import psycopg2
import json
from datetime import datetime
import logging
class TransactionMonitor:
def __init__(self, connection_params):
self.conn_params = connection_params
self.setup_logging()
def setup_logging(self):
"""Setup logging."""
logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(__name__)
def get_active_transactions(self):
"""Get information about active transactions."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
pid,
usename,
application_name,
client_addr,
backend_start,
state,
query_start,
state_change,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY query_start
""")
active_transactions = cur.fetchall()
return active_transactions
except Exception as e:
self.logger.error(f"Error getting active transactions: {e}")
return []
finally:
conn.close()
def get_lock_information(self):
"""Get information about current locks."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
l.locktype,
l.database,
l.relation,
l.page,
l.tuple,
l.virtualxid,
l.transactionid,
l.classid,
l.objid,
l.objsubid,
l.virtualtransaction,
l.pid,
l.mode,
l.granted,
a.usename,
a.query,
a.query_start,
a.state
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.granted = false
ORDER BY l.pid
""")
lock_info = cur.fetchall()
return lock_info
except Exception as e:
self.logger.error(f"Error getting lock information: {e}")
return []
finally:
conn.close()
def get_deadlock_information(self):
"""Get information about recent deadlocks."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
log_time,
user_name,
database_name,
process_id,
session_id,
session_line_num,
command_tag,
session_start_time,
virtual_transaction_id,
transaction_id,
error_severity,
sql_state_code,
message,
detail,
hint,
internal_query,
internal_query_pos,
context,
query,
query_pos,
location,
application_name
FROM pg_log
WHERE message LIKE '%deadlock%'
ORDER BY log_time DESC
LIMIT 10
""")
deadlock_info = cur.fetchall()
return deadlock_info
except Exception as e:
self.logger.error(f"Error getting deadlock information: {e}")
return []
finally:
conn.close()
def get_transaction_statistics(self):
"""Get transaction statistics."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
datname,
numbackends,
xact_commit,
xact_rollback,
blks_read,
blks_hit,
tup_returned,
tup_fetched,
tup_inserted,
tup_updated,
tup_deleted,
conflicts,
temp_files,
temp_bytes,
deadlocks,
blk_read_time,
blk_write_time
FROM pg_stat_database
WHERE datname = current_database()
""")
stats = cur.fetchone()
return stats
except Exception as e:
self.logger.error(f"Error getting transaction statistics: {e}")
return None
finally:
conn.close()
def generate_transaction_report(self):
"""Generate comprehensive transaction report."""
active_transactions = self.get_active_transactions()
lock_info = self.get_lock_information()
deadlock_info = self.get_deadlock_information()
transaction_stats = self.get_transaction_statistics()
report = {
'report_timestamp': datetime.now().isoformat(),
'active_transactions_count': len(active_transactions),
'active_transactions': active_transactions,
'lock_information': lock_info,
'deadlock_information': deadlock_info,
'transaction_statistics': transaction_stats
}
return report
# Usage
if __name__ == "__main__":
monitor = TransactionMonitor({
'host': 'localhost',
'database': 'production',
'user': 'monitor_user',
'password': 'monitor_password'
})
report = monitor.generate_transaction_report()
print(json.dumps(report, indent=2))
Error Handling and Recovery¶
Transaction Error Handling¶
-- Create function with comprehensive error handling
CREATE OR REPLACE FUNCTION process_payment(
user_id INTEGER,
amount NUMERIC(10,2),
payment_method VARCHAR(50)
)
RETURNS INTEGER AS $$
DECLARE
transaction_id INTEGER;
current_balance NUMERIC(10,2);
BEGIN
-- Start transaction
BEGIN
-- Check user balance
SELECT balance INTO current_balance
FROM user_accounts
WHERE user_id = user_id FOR UPDATE;
IF current_balance < amount THEN
RAISE EXCEPTION 'Insufficient funds: % available, % requested', current_balance, amount;
END IF;
-- Create transaction record
INSERT INTO transactions (user_id, amount, transaction_type, payment_method)
VALUES (user_id, amount, 'payment', payment_method)
RETURNING id INTO transaction_id;
-- Update user balance
UPDATE user_accounts
SET balance = balance - amount
WHERE user_id = user_id;
-- Log transaction
INSERT INTO transaction_logs (transaction_id, action, timestamp)
VALUES (transaction_id, 'payment_processed', CURRENT_TIMESTAMP);
RETURN transaction_id;
EXCEPTION
WHEN insufficient_funds THEN
-- Log the error
INSERT INTO error_logs (user_id, error_type, error_message, timestamp)
VALUES (user_id, 'insufficient_funds', SQLERRM, CURRENT_TIMESTAMP);
RAISE;
WHEN OTHERS THEN
-- Log unexpected errors
INSERT INTO error_logs (user_id, error_type, error_message, timestamp)
VALUES (user_id, 'unexpected_error', SQLERRM, CURRENT_TIMESTAMP);
RAISE;
END;
END;
$$ LANGUAGE plpgsql;
Transaction Recovery¶
-- Create function for transaction recovery
CREATE OR REPLACE FUNCTION recover_failed_transactions()
RETURNS INTEGER AS $$
DECLARE
failed_transaction RECORD;
recovery_count INTEGER := 0;
BEGIN
-- Find failed transactions
FOR failed_transaction IN
SELECT * FROM transactions
WHERE status = 'failed'
AND created_at > CURRENT_TIMESTAMP - INTERVAL '1 hour'
LOOP
BEGIN
-- Attempt to recover the transaction
UPDATE transactions
SET status = 'recovered',
updated_at = CURRENT_TIMESTAMP
WHERE id = failed_transaction.id;
recovery_count := recovery_count + 1;
EXCEPTION
WHEN OTHERS THEN
-- Log recovery failure
INSERT INTO error_logs (transaction_id, error_type, error_message, timestamp)
VALUES (failed_transaction.id, 'recovery_failed', SQLERRM, CURRENT_TIMESTAMP);
END;
END LOOP;
RETURN recovery_count;
END;
$$ LANGUAGE plpgsql;
TL;DR Runbook¶
Quick Start¶
-- 1. Set appropriate isolation levels
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Your transaction code here
COMMIT;
-- 2. Use row-level locking
SELECT * FROM table_name WHERE id = 1 FOR UPDATE;
-- 3. Implement savepoints for nested transactions
SAVEPOINT my_savepoint;
-- Your code here
ROLLBACK TO SAVEPOINT my_savepoint;
-- 4. Handle errors in transactions
BEGIN;
-- Your code here
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END;
Essential Patterns¶
# Complete PostgreSQL transactions and concurrency setup
def setup_postgresql_transactions_concurrency():
# 1. Transaction isolation levels
# 2. Lock management
# 3. Transaction patterns
# 4. Concurrency control
# 5. Transaction monitoring
# 6. Error handling and recovery
# 7. Performance optimization
# 8. Deadlock prevention
print("PostgreSQL transactions and concurrency setup complete!")
This guide provides the complete machinery for PostgreSQL transactions and concurrency excellence. Each pattern includes implementation examples, concurrency strategies, and real-world usage patterns for enterprise PostgreSQL transaction systems.