Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Hero image for SQL Transactions, Isolation Levels, and Locking: A Complete Guide to Concurrent Database Programming

SQL Transactions, Isolation Levels, and Locking: A Complete Guide to Concurrent Database Programming

SQL⚡ Practitioner17 min readMay 23, 2026Updated Jun 5, 2026
Table of Contents
  • Prerequisites
  • Understanding Transactions: Beyond Simple Commits
  • Isolation Levels: Controlling What Transactions See
  • Read Uncommitted: The Wild West
  • Read Committed: The Practical Default
  • Repeatable Read: Consistent Snapshots
  • Serializable: The Gold Standard
  • Locking Mechanisms: Fine-Grained Control
  • Row-Level Locking
  • Advisory Locks: Custom Coordination
  • Lock Modes and Compatibility
  • Deadlock Detection and Prevention

Picture this: You're building a financial trading system where thousands of transactions happen per second. Two users simultaneously try to transfer money from the same account, or a batch job updates inventory while customers are placing orders. Without proper transaction management, you could end up with corrupted data, phantom balances, or inventory that exists only in theory. This is where SQL transactions, isolation levels, and locking mechanisms become your safety net.

Understanding these concepts isn't just academic—it's the difference between a system that works under load and one that crumbles when real users start hitting it. Every senior database developer has stories of production incidents caused by missing transactions or incorrect isolation levels, and the best way to avoid becoming one of those stories is to master these fundamentals now.

What you'll learn:

  • How to design bulletproof transactions that maintain data integrity under concurrent access
  • When and how to choose the right isolation level for your specific use case
  • How different locking strategies impact performance and concurrency
  • Techniques for debugging deadlocks and optimizing transaction performance
  • Real-world patterns for handling complex business logic with multiple database operations

Prerequisites

You should be comfortable writing multi-table SQL queries, understand basic concepts like ACID properties, and have experience with at least one major database system (PostgreSQL, MySQL, SQL Server, or Oracle). We'll use PostgreSQL for most examples, but the concepts apply broadly across database systems.

Understanding Transactions: Beyond Simple Commits

A transaction isn't just a way to group SQL statements—it's a contract with your database that either all operations succeed or none of them do. But the real power of transactions becomes apparent when you understand how they interact with concurrent operations.

Let's start with a realistic scenario: an e-commerce inventory system where we need to process an order, update stock levels, and record the transaction history.

-- Setting up our test environment
CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    price DECIMAL(10,2),
    stock_quantity INTEGER,
    reserved_quantity INTEGER DEFAULT 0,
    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    customer_id INTEGER,
    total_amount DECIMAL(10,2),
    status VARCHAR(50),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE order_items (
    item_id SERIAL PRIMARY KEY,
    order_id INTEGER REFERENCES orders(order_id),
    product_id INTEGER REFERENCES products(product_id),
    quantity INTEGER,
    unit_price DECIMAL(10,2)
);

-- Sample data
INSERT INTO products (name, price, stock_quantity) VALUES
    ('Gaming Laptop', 1299.99, 50),
    ('Wireless Headphones', 199.99, 200),
    ('Mechanical Keyboard', 129.99, 75);

Now, let's implement order processing with proper transaction handling:

-- Order processing transaction
BEGIN;

-- First, let's reserve the inventory
UPDATE products 
SET reserved_quantity = reserved_quantity + 2,
    stock_quantity = stock_quantity - 2,
    last_updated = CURRENT_TIMESTAMP
WHERE product_id = 1 AND stock_quantity >= 2;

-- Check if the update affected any rows (i.e., if we had enough stock)
-- In a real application, you'd check this in your application code
-- For demonstration, we'll use a conditional approach

-- Create the order
INSERT INTO orders (customer_id, total_amount, status)
VALUES (12345, 2599.98, 'processing')
RETURNING order_id;

-- Let's say the returned order_id is 1001 for our example
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (1001, 1, 2, 1299.99);

-- If everything looks good, commit the transaction
COMMIT;

This basic transaction works, but what happens when multiple customers try to order the same product simultaneously? Let's explore this with a more sophisticated approach:

-- A more robust order processing function
CREATE OR REPLACE FUNCTION process_order(
    p_customer_id INTEGER,
    p_product_id INTEGER,
    p_quantity INTEGER
) RETURNS TABLE(success BOOLEAN, message TEXT, order_id INTEGER) AS $$
DECLARE
    v_order_id INTEGER;
    v_available_stock INTEGER;
    v_product_price DECIMAL(10,2);
BEGIN
    -- Start transaction is implicit in a function
    
    -- Lock the product row to prevent concurrent modifications
    SELECT stock_quantity, price INTO v_available_stock, v_product_price
    FROM products 
    WHERE product_id = p_product_id
    FOR UPDATE;
    
    -- Check if we have enough stock
    IF v_available_stock IS NULL THEN
        RETURN QUERY SELECT FALSE, 'Product not found', NULL::INTEGER;
        RETURN;
    END IF;
    
    IF v_available_stock < p_quantity THEN
        RETURN QUERY SELECT FALSE, 
               FORMAT('Insufficient stock. Available: %s, Requested: %s', 
                      v_available_stock, p_quantity), 
               NULL::INTEGER;
        RETURN;
    END IF;
    
    -- Update inventory
    UPDATE products 
    SET stock_quantity = stock_quantity - p_quantity,
        last_updated = CURRENT_TIMESTAMP
    WHERE product_id = p_product_id;
    
    -- Create order
    INSERT INTO orders (customer_id, total_amount, status)
    VALUES (p_customer_id, v_product_price * p_quantity, 'confirmed')
    RETURNING orders.order_id INTO v_order_id;
    
    -- Create order item
    INSERT INTO order_items (order_id, product_id, quantity, unit_price)
    VALUES (v_order_id, p_product_id, p_quantity, v_product_price);
    
    RETURN QUERY SELECT TRUE, 'Order processed successfully', v_order_id;
END;
$$ LANGUAGE plpgsql;

The FOR UPDATE clause in this function is crucial—it acquires a row-level lock on the product record, ensuring that no other transaction can modify it until our transaction completes. This prevents the classic "lost update" problem where two transactions read the same value and both try to update it based on their original read.

Isolation Levels: Controlling What Transactions See

Isolation levels determine how much transactions can see of each other's uncommitted changes. Each level represents a trade-off between data consistency and performance. Let's explore each level with practical examples.

Read Uncommitted: The Wild West

At this level, transactions can see uncommitted changes from other transactions. This creates the possibility of "dirty reads" where you might read data that gets rolled back.

-- Session 1: Start a transaction and update inventory
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
UPDATE products SET stock_quantity = 0 WHERE product_id = 1;
-- Don't commit yet!

-- Session 2 (in another connection): Try to read the data
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT stock_quantity FROM products WHERE product_id = 1;
-- This will show 0, even though Session 1 hasn't committed

-- Back in Session 1: Roll back the change
ROLLBACK;

-- Session 2 will have seen data that never actually existed!

This isolation level is rarely used in practice except for reporting scenarios where approximate data is acceptable and performance is critical.

Read Committed: The Practical Default

Most databases default to READ COMMITTED, which prevents dirty reads but allows non-repeatable reads and phantom reads. Let's see this in action:

-- Session 1: Read data, then modify it
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT stock_quantity FROM products WHERE product_id = 1; -- Returns 50

-- Session 2: Modify the same data and commit
BEGIN;
UPDATE products SET stock_quantity = 45 WHERE product_id = 1;
COMMIT;

-- Back in Session 1: Read the same row again
SELECT stock_quantity FROM products WHERE product_id = 1; -- Now returns 45!
-- This is a "non-repeatable read"
COMMIT;

This behavior is often acceptable and even desirable—you want to see committed changes from other transactions. But sometimes you need stronger consistency guarantees.

Repeatable Read: Consistent Snapshots

REPEATABLE READ prevents non-repeatable reads by ensuring that once you read a row in a transaction, it will always return the same value for subsequent reads:

-- Let's demonstrate with a financial transfer scenario
-- Session 1: Check account balances
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT account_id, balance FROM accounts WHERE account_id IN (1001, 1002);
-- Shows: 1001 has $1000, 1002 has $500

-- Session 2: Transfer money between accounts
BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE account_id = 1001;
UPDATE accounts SET balance = balance + 200 WHERE account_id = 1002;
COMMIT;

-- Back in Session 1: Check balances again
SELECT account_id, balance FROM accounts WHERE account_id IN (1001, 1002);
-- Still shows: 1001 has $1000, 1002 has $500 (repeatable read)

-- But what about new rows?
INSERT INTO accounts (account_id, balance) VALUES (1003, 300);
SELECT COUNT(*) FROM accounts; -- This might show the new row (phantom read)
COMMIT;

REPEATABLE READ prevents non-repeatable reads but may still allow phantom reads (new rows appearing).

Serializable: The Gold Standard

SERIALIZABLE provides the strongest isolation by ensuring that transactions execute as if they were run one after another:

-- Demonstrating serializable isolation with inventory management
-- Session 1: Calculate total inventory value
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(stock_quantity * price) AS total_inventory_value FROM products;

-- Session 2: Add a new high-value product
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
INSERT INTO products (name, price, stock_quantity) 
VALUES ('Enterprise Server', 15000.00, 5);
COMMIT;

-- Back in Session 1: Recalculate inventory value
SELECT SUM(stock_quantity * price) AS total_inventory_value FROM products;
-- The result will be the same as the first query, preventing phantom reads

-- If Session 1 tries to commit, it might get a serialization failure
-- if the database detects a conflict
COMMIT; -- This might fail with a serialization error

Warning: SERIALIZABLE isolation can lead to serialization failures where transactions must be retried. Always implement retry logic when using this isolation level in production applications.

Locking Mechanisms: Fine-Grained Control

Database locking goes beyond isolation levels, giving you explicit control over how concurrent access is managed. Understanding the different types of locks helps you write more efficient and safer code.

Row-Level Locking

Row-level locks are the most granular and often the most efficient:

-- Exclusive row lock (FOR UPDATE)
-- Perfect for updating specific records
BEGIN;
SELECT * FROM products 
WHERE product_id = 1 
FOR UPDATE;

-- This row is now locked; other transactions will wait
UPDATE products SET stock_quantity = stock_quantity - 1 WHERE product_id = 1;
COMMIT;

-- Shared row lock (FOR SHARE)
-- Allows other shared locks but prevents updates
BEGIN;
SELECT * FROM products 
WHERE product_id = 1 
FOR SHARE;

-- Other transactions can read but not update this row
-- until this transaction commits
COMMIT;

Advisory Locks: Custom Coordination

Advisory locks let you implement custom locking strategies:

-- Using advisory locks for business logic coordination
CREATE OR REPLACE FUNCTION transfer_funds(
    from_account INTEGER,
    to_account INTEGER,
    amount DECIMAL
) RETURNS BOOLEAN AS $$
BEGIN
    -- Always lock accounts in the same order to prevent deadlocks
    IF from_account < to_account THEN
        PERFORM pg_advisory_xact_lock(from_account);
        PERFORM pg_advisory_xact_lock(to_account);
    ELSE
        PERFORM pg_advisory_xact_lock(to_account);
        PERFORM pg_advisory_xact_lock(from_account);
    END IF;
    
    -- Perform the transfer logic here
    UPDATE accounts SET balance = balance - amount WHERE account_id = from_account;
    UPDATE accounts SET balance = balance + amount WHERE account_id = to_account;
    
    RETURN TRUE;
    -- Advisory locks are automatically released on transaction end
END;
$$ LANGUAGE plpgsql;

Lock Modes and Compatibility

Different lock modes have different compatibility rules:

-- Demonstrating lock conflicts
-- Session 1: Acquire a shared lock
BEGIN;
SELECT * FROM products WHERE product_id = 1 FOR SHARE;

-- Session 2: Try different operations
BEGIN;
-- This works (shared locks are compatible)
SELECT * FROM products WHERE product_id = 1 FOR SHARE; 

-- This blocks (exclusive locks conflict with shared locks)
SELECT * FROM products WHERE product_id = 1 FOR UPDATE; -- Waits

-- This also blocks
UPDATE products SET price = 1399.99 WHERE product_id = 1; -- Waits

Deadlock Detection and Prevention

Deadlocks occur when transactions wait for each other in a circular pattern. Here's how to handle them:

-- Example that creates a deadlock
-- Session 1:
BEGIN;
UPDATE products SET price = 1399.99 WHERE product_id = 1;
-- Now try to update product 2
UPDATE products SET price = 249.99 WHERE product_id = 2; -- May deadlock

-- Session 2 (simultaneously):
BEGIN;
UPDATE products SET price = 249.99 WHERE product_id = 2;
-- Now try to update product 1
UPDATE products SET price = 1399.99 WHERE product_id = 1; -- May deadlock

Prevention strategies:

-- Strategy 1: Always lock resources in the same order
CREATE OR REPLACE FUNCTION update_multiple_products() RETURNS VOID AS $$
BEGIN
    -- Always lock in ascending product_id order
    UPDATE products SET last_updated = CURRENT_TIMESTAMP 
    WHERE product_id IN (1, 2, 3) 
    ORDER BY product_id;  -- This prevents deadlocks
END;
$$ LANGUAGE plpgsql;

-- Strategy 2: Use shorter transactions
CREATE OR REPLACE FUNCTION quick_inventory_update(
    p_product_id INTEGER,
    p_quantity INTEGER
) RETURNS VOID AS $$
BEGIN
    -- Keep the critical section as short as possible
    UPDATE products 
    SET stock_quantity = stock_quantity - p_quantity,
        last_updated = CURRENT_TIMESTAMP
    WHERE product_id = p_product_id;
    
    -- Do non-critical work outside the transaction if possible
END;
$$ LANGUAGE plpgsql;

Performance Implications and Optimization

Transaction management directly impacts performance. Here are key optimization strategies:

Batch Operations Wisely

-- Inefficient: One transaction per operation
DO $$
DECLARE
    i INTEGER;
BEGIN
    FOR i IN 1..1000 LOOP
        BEGIN;
        INSERT INTO orders (customer_id, total_amount, status) 
        VALUES (i, 100.00, 'pending');
        COMMIT;
    END LOOP;
END $$;

-- Efficient: Batch operations in fewer transactions
DO $$
DECLARE
    i INTEGER;
BEGIN
    FOR i IN 1..10 LOOP  -- 10 batches of 100 each
        BEGIN;
        INSERT INTO orders (customer_id, total_amount, status)
        SELECT 
            ((i-1) * 100) + generate_series(1, 100),
            100.00,
            'pending';
        COMMIT;
    END LOOP;
END $$;

Monitor Lock Contention

-- Query to identify lock contention (PostgreSQL)
SELECT 
    blocked_locks.pid AS blocked_pid,
    blocked_activity.usename AS blocked_user,
    blocking_locks.pid AS blocking_pid,
    blocking_activity.usename AS blocking_user,
    blocked_activity.query AS blocked_statement,
    blocking_activity.query AS current_statement_in_blocking_process
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity 
    ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks 
    ON blocking_locks.locktype = blocked_locks.locktype
    AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
    AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
    AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
    AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
    AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
    AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
    AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
    AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
    AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
    AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity 
    ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.GRANTED;

Hands-On Exercise: Building a Reservation System

Let's implement a concert ticket reservation system that handles concurrent bookings correctly:

-- Create the reservation system schema
CREATE TABLE venues (
    venue_id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    total_capacity INTEGER
);

CREATE TABLE events (
    event_id SERIAL PRIMARY KEY,
    venue_id INTEGER REFERENCES venues(venue_id),
    name VARCHAR(255),
    event_date TIMESTAMP,
    ticket_price DECIMAL(10,2),
    tickets_available INTEGER
);

CREATE TABLE reservations (
    reservation_id SERIAL PRIMARY KEY,
    event_id INTEGER REFERENCES events(event_id),
    customer_email VARCHAR(255),
    num_tickets INTEGER,
    reservation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(50) DEFAULT 'pending',
    expires_at TIMESTAMP
);

-- Sample data
INSERT INTO venues (name, total_capacity) VALUES ('Madison Square Garden', 20000);
INSERT INTO events (venue_id, name, event_date, ticket_price, tickets_available)
VALUES (1, 'Rock Concert 2024', '2024-06-15 19:00:00', 89.99, 1000);

Now implement the reservation function:

CREATE OR REPLACE FUNCTION create_reservation(
    p_event_id INTEGER,
    p_customer_email VARCHAR(255),
    p_num_tickets INTEGER
) RETURNS TABLE(
    success BOOLEAN,
    message TEXT,
    reservation_id INTEGER,
    expires_at TIMESTAMP
) AS $$
DECLARE
    v_reservation_id INTEGER;
    v_available_tickets INTEGER;
    v_expiry_time TIMESTAMP;
BEGIN
    -- Set isolation level for this function
    SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
    
    -- Calculate expiry time (15 minutes from now)
    v_expiry_time := CURRENT_TIMESTAMP + INTERVAL '15 minutes';
    
    -- Lock the event row and check availability
    SELECT tickets_available INTO v_available_tickets
    FROM events
    WHERE event_id = p_event_id
    FOR UPDATE;
    
    -- Validate the request
    IF v_available_tickets IS NULL THEN
        RETURN QUERY SELECT FALSE, 'Event not found', NULL::INTEGER, NULL::TIMESTAMP;
        RETURN;
    END IF;
    
    IF v_available_tickets < p_num_tickets THEN
        RETURN QUERY SELECT FALSE, 
               FORMAT('Only %s tickets available', v_available_tickets),
               NULL::INTEGER, NULL::TIMESTAMP;
        RETURN;
    END IF;
    
    -- Create the reservation
    INSERT INTO reservations (event_id, customer_email, num_tickets, expires_at)
    VALUES (p_event_id, p_customer_email, p_num_tickets, v_expiry_time)
    RETURNING reservations.reservation_id INTO v_reservation_id;
    
    -- Update available tickets
    UPDATE events 
    SET tickets_available = tickets_available - p_num_tickets
    WHERE event_id = p_event_id;
    
    RETURN QUERY SELECT TRUE, 'Reservation created successfully', 
                        v_reservation_id, v_expiry_time;
END;
$$ LANGUAGE plpgsql;

Add a function to handle reservation expiry:

CREATE OR REPLACE FUNCTION cleanup_expired_reservations()
RETURNS INTEGER AS $$
DECLARE
    expired_count INTEGER;
BEGIN
    -- Use a separate transaction for each cleanup operation
    -- to minimize lock time
    WITH expired_reservations AS (
        UPDATE reservations 
        SET status = 'expired'
        WHERE status = 'pending' 
        AND expires_at < CURRENT_TIMESTAMP
        RETURNING event_id, num_tickets
    )
    UPDATE events 
    SET tickets_available = tickets_available + er.num_tickets
    FROM expired_reservations er
    WHERE events.event_id = er.event_id;
    
    GET DIAGNOSTICS expired_count = ROW_COUNT;
    
    RETURN expired_count;
END;
$$ LANGUAGE plpgsql;

Test the system under concurrent load:

-- Simulate concurrent reservations
-- Run these in separate sessions simultaneously

-- Session 1:
SELECT * FROM create_reservation(1, 'user1@example.com', 5);

-- Session 2:
SELECT * FROM create_reservation(1, 'user2@example.com', 3);

-- Session 3:
SELECT * FROM create_reservation(1, 'user3@example.com', 2);

-- Check the results
SELECT 
    r.reservation_id,
    r.customer_email,
    r.num_tickets,
    r.status,
    e.tickets_available
FROM reservations r
JOIN events e ON r.event_id = e.event_id
WHERE r.event_id = 1
ORDER BY r.reservation_time;

Common Mistakes & Troubleshooting

Mistake 1: Forgetting Transaction Boundaries

-- Wrong: Operations that should be atomic aren't grouped
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
-- If this fails, the first update is already committed!
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;

-- Right: Wrap related operations in a transaction
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;

Mistake 2: Using Wrong Isolation Levels

-- Wrong: Using READ UNCOMMITTED for financial calculations
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT SUM(balance) FROM accounts; -- Could include uncommitted changes!

-- Right: Use appropriate isolation for your use case
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(balance) FROM accounts; -- Consistent snapshot

Mistake 3: Long-Running Transactions

-- Wrong: Keeping transactions open too long
BEGIN;
SELECT * FROM products FOR UPDATE; -- Locks all product rows
-- ... lots of application logic that takes minutes ...
COMMIT; -- Finally releases the locks

-- Right: Minimize transaction time
-- Do preparation work outside the transaction
BEGIN;
-- Only the essential database work here
UPDATE products SET stock_quantity = stock_quantity - 1 WHERE product_id = 123;
COMMIT;
-- Do post-processing outside the transaction

Debugging Deadlocks

When you encounter deadlocks, examine the deadlock graph:

-- Enable deadlock logging (PostgreSQL)
SET deadlock_timeout = '1s';
SET log_lock_waits = on;

-- Check current locks
SELECT 
    locktype,
    mode,
    granted,
    pid,
    relation::regclass
FROM pg_locks 
WHERE NOT granted
ORDER BY pid;

Performance Troubleshooting

-- Monitor transaction times
SELECT 
    pid,
    state,
    query_start,
    state_change,
    EXTRACT(EPOCH FROM (now() - query_start)) AS query_duration_seconds
FROM pg_stat_activity 
WHERE state != 'idle'
ORDER BY query_start;

-- Find blocking queries
SELECT 
    activity.pid,
    activity.usename,
    activity.query,
    blocking.pid AS blocking_pid,
    blocking.query AS blocking_query
FROM pg_stat_activity activity
JOIN pg_locks locks ON locks.pid = activity.pid
JOIN pg_locks blocking_locks ON (
    locks.locktype = blocking_locks.locktype
    AND locks.database IS NOT DISTINCT FROM blocking_locks.database
    AND locks.relation IS NOT DISTINCT FROM blocking_locks.relation
    AND locks.page IS NOT DISTINCT FROM blocking_locks.page
    AND locks.tuple IS NOT DISTINCT FROM blocking_locks.tuple
    AND locks.virtualxid IS NOT DISTINCT FROM blocking_locks.virtualxid
    AND locks.transactionid IS NOT DISTINCT FROM blocking_locks.transactionid
    AND locks.classid IS NOT DISTINCT FROM blocking_locks.classid
    AND locks.objid IS NOT DISTINCT FROM blocking_locks.objid
    AND locks.objsubid IS NOT DISTINCT FROM blocking_locks.objsubid
    AND locks.pid != blocking_locks.pid
)
JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
WHERE NOT locks.granted;

Summary & Next Steps

You now understand how to use transactions, isolation levels, and locking to build robust, concurrent database applications. The key takeaways are:

Transaction Design: Always group related operations that must succeed or fail together. Keep transactions as short as possible while maintaining data integrity.

Isolation Levels: Choose the right level for your use case—READ COMMITTED for most applications, REPEATABLE READ when you need consistent snapshots, and SERIALIZABLE only when absolute consistency is required.

Locking Strategy: Use row-level locks judiciously, always acquire locks in a consistent order to prevent deadlocks, and monitor lock contention in production.

Performance: Batch operations appropriately, monitor long-running transactions, and always test your locking strategy under realistic concurrent load.

The reservation system you built demonstrates real-world transaction patterns you'll encounter in production systems. Practice extending it with features like payment processing, waitlists, and seat selection—each will present new challenges for transaction design.

Next, explore database-specific features like PostgreSQL's LISTEN/NOTIFY for event-driven architectures, or SQL Server's snapshot isolation. Consider learning about distributed transactions and two-phase commit protocols if you work with microservices architectures.

Most importantly, always test your transaction logic under concurrent load before deploying to production. The subtle bugs that emerge under concurrency are often the most difficult to debug and the most damaging to user experience.

Learning Path: Advanced SQL Queries

Previous

Stored Procedures and User-Defined Functions: Building Reusable SQL Logic

Next

Advanced JOIN Patterns: Self Joins, Anti Joins, and Semi Joins

Related Articles

SQL🔥 Expert

Graph Traversal in SQL: Recursive CTEs and Adjacency Lists for Network and Relationship Analysis

31 min
SQL⚡ Practitioner

Fuzzy Matching and Deduplication in SQL: Using Similarity Functions and Blocking Strategies to Find Near-Duplicate Records

22 min
SQL🌱 Foundation

Filtering and Transforming Data with SQL String Functions: LIKE, REGEXP, SUBSTRING, and REPLACE

15 min

On this page

  • Prerequisites
  • Understanding Transactions: Beyond Simple Commits
  • Isolation Levels: Controlling What Transactions See
  • Read Uncommitted: The Wild West
  • Read Committed: The Practical Default
  • Repeatable Read: Consistent Snapshots
  • Serializable: The Gold Standard
  • Locking Mechanisms: Fine-Grained Control
  • Row-Level Locking
  • Advisory Locks: Custom Coordination
Performance Implications and Optimization
  • Batch Operations Wisely
  • Monitor Lock Contention
  • Hands-On Exercise: Building a Reservation System
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting Transaction Boundaries
  • Mistake 2: Using Wrong Isolation Levels
  • Mistake 3: Long-Running Transactions
  • Debugging Deadlocks
  • Performance Troubleshooting
  • Summary & Next Steps
  • Lock Modes and Compatibility
  • Deadlock Detection and Prevention
  • Performance Implications and Optimization
  • Batch Operations Wisely
  • Monitor Lock Contention
  • Hands-On Exercise: Building a Reservation System
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting Transaction Boundaries
  • Mistake 2: Using Wrong Isolation Levels
  • Mistake 3: Long-Running Transactions
  • Debugging Deadlocks
  • Performance Troubleshooting
  • Summary & Next Steps