Skip to content

Database Transactions

Intermediate

Quick Reference

sql
-- Start a transaction
BEGIN TRANSACTION;
START TRANSACTION;  -- Alternative syntax
BEGIN;              -- Short form (PostgreSQL, SQLite)

-- Commit changes
COMMIT;

-- Rollback changes
ROLLBACK;

-- Savepoint for partial rollback
BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  SAVEPOINT sp1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
  ROLLBACK TO SAVEPOINT sp1;  -- Undo only second UPDATE
COMMIT;

-- Set isolation level
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
  -- Transaction statements
COMMIT;

-- Locking reads
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;  -- Exclusive lock
SELECT * FROM accounts WHERE id = 1 FOR SHARE;   -- Shared lock

-- Skip locked rows (job queue pattern)
SELECT * FROM jobs
WHERE status = 'pending'
LIMIT 10
FOR UPDATE SKIP LOCKED;

Overview

A transaction is a sequence of one or more SQL operations that are executed as a single logical unit of work. Transactions ensure data integrity by guaranteeing that either all operations succeed together, or none of them do.

Why Use Transactions?:

  • Data consistency: Prevent partial updates that leave data in an invalid state
  • Concurrent access: Allow multiple users to safely access data simultaneously
  • Error recovery: Undo changes when errors occur
  • Business logic: Enforce multi-step operations as atomic units

Key Concepts:

  • Transaction control: BEGIN, COMMIT, ROLLBACK
  • ACID properties: Atomicity, Consistency, Isolation, Durability
  • Locking: Controlling access to data during transactions
  • Isolation levels: Balancing consistency vs. performance
  • Savepoints: Creating checkpoints within transactions

Transaction Basics

Starting a Transaction

sql
-- PostgreSQL, SQLite
BEGIN;
-- or
BEGIN TRANSACTION;

-- MySQL
START TRANSACTION;
-- or
BEGIN;

-- SQL Server
BEGIN TRANSACTION;
-- or with name
BEGIN TRANSACTION transfer_funds;

-- Oracle (implicit)
-- Transactions start automatically with first DML statement
UPDATE accounts SET balance = 1000 WHERE id = 1;

Committing a Transaction

Committing makes all changes permanent.

sql
BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;  -- Both updates are now permanent

Rolling Back a Transaction

Rolling back undoes all changes made in the transaction.

sql
BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;

  -- Check if something went wrong
  IF (SELECT balance FROM accounts WHERE id = 1) < 0 THEN
    ROLLBACK;  -- Undo both updates
  ELSE
    COMMIT;
  END IF;

Autocommit Mode

Most databases run in autocommit mode by default, where each statement is automatically committed.

sql
-- Check autocommit status (MySQL)
SELECT @@autocommit;

-- Disable autocommit (MySQL)
SET autocommit = 0;

-- Now statements require explicit COMMIT
UPDATE accounts SET balance = 1000 WHERE id = 1;
COMMIT;  -- Required

-- Re-enable autocommit
SET autocommit = 1;

-- PostgreSQL (controlled by client)
-- In psql: \set AUTOCOMMIT off

ACID Properties

Transactions follow ACID properties to ensure reliability:

  • Atomicity: All operations succeed or all fail (no partial updates)
  • Consistency: Database moves from one valid state to another
  • Isolation: Concurrent transactions don't interfere
  • Durability: Committed changes survive system failures

Atomicity Example

sql
-- Money transfer: both updates or neither
BEGIN TRANSACTION;
  -- Debit account
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;

  -- Credit account
  UPDATE accounts SET balance = balance + 500 WHERE id = 2;

  -- If either fails, both are rolled back
COMMIT;

Consistency Example

sql
-- Constraints enforce consistency
CREATE TABLE accounts (
  id INTEGER PRIMARY KEY,
  balance DECIMAL(10,2) CHECK (balance >= 0)  -- Never negative
);

BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
  -- If this violates CHECK constraint, transaction rolls back
COMMIT;

For detailed coverage of ACID properties, see the ACID guide.

Locking Mechanisms

Locks prevent conflicting concurrent access to data. Databases use locks automatically, but you can also request explicit locks.

Types of Locks

Shared Lock (S): Multiple transactions can read, none can write

Exclusive Lock (X): Only one transaction can read/write

Update Lock (U): Prevents deadlocks in read-then-update scenarios

Implicit Locking

Databases automatically acquire locks based on operations:

sql
-- SELECT: Shared lock (in some isolation levels)
SELECT * FROM accounts WHERE id = 1;

-- UPDATE/DELETE: Exclusive lock
UPDATE accounts SET balance = 1000 WHERE id = 1;
-- Row is exclusively locked until COMMIT or ROLLBACK

-- INSERT: Exclusive lock on new row
INSERT INTO accounts (id, balance) VALUES (3, 1000);

Explicit Locking

SELECT FOR UPDATE

Acquires exclusive lock for reading, preventing other transactions from modifying or locking the rows.

sql
-- Lock rows for update
BEGIN TRANSACTION;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  -- Row is locked, other transactions must wait

  -- Now safe to update based on read value
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- Lock released

Use cases:

  • Read-then-update patterns
  • Preventing lost updates
  • Queue processing
sql
-- Job queue: Lock next available job
BEGIN TRANSACTION;
  SELECT * FROM jobs
  WHERE status = 'pending'
  ORDER BY priority DESC
  LIMIT 1
  FOR UPDATE;

  -- Process job...
  UPDATE jobs SET status = 'processing' WHERE id = @job_id;
COMMIT;

SELECT FOR SHARE

Acquires shared lock, allowing other transactions to read but not modify.

sql
BEGIN TRANSACTION;
  SELECT balance FROM accounts WHERE id = 1 FOR SHARE;
  -- Others can read but not modify

  -- Use value for calculations
  -- (without modifying)
COMMIT;

Lock Modifiers

NOWAIT

Don't wait for locks, fail immediately if locked.

sql
BEGIN TRANSACTION;
  SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
  -- Error if row is locked
COMMIT;

Use case: Try to acquire lock, handle failure gracefully

sql
-- Try to process job without waiting
BEGIN TRANSACTION;
  BEGIN TRY
    SELECT * FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
    -- Process job
    COMMIT;
  END TRY
  BEGIN CATCH
    ROLLBACK;
    -- Job is locked, try another one
  END CATCH

SKIP LOCKED

Skip rows that are locked, return unlocked rows only.

sql
-- Multiple workers processing job queue
BEGIN TRANSACTION;
  SELECT * FROM jobs
  WHERE status = 'pending'
  ORDER BY priority DESC
  LIMIT 10
  FOR UPDATE SKIP LOCKED;

  -- Each worker gets different jobs
  -- No waiting or conflicts
  UPDATE jobs SET status = 'processing' WHERE id IN (...);
COMMIT;

Use cases:

  • Job queues with multiple workers
  • Batch processing
  • Concurrent task distribution

Lock Granularity

Row-level locks: Lock individual rows (InnoDB, PostgreSQL, SQL Server, Oracle)

sql
-- Lock single row
UPDATE accounts SET balance = 1000 WHERE id = 1;
-- Only this row is locked

Table-level locks: Lock entire table (MyISAM, some operations)

sql
-- Explicit table lock (MySQL)
LOCK TABLES accounts WRITE;
-- Entire table locked
UPDATE accounts SET balance = balance * 1.1;
UNLOCK TABLES;

-- PostgreSQL
LOCK TABLE accounts IN EXCLUSIVE MODE;
UPDATE accounts SET balance = balance * 1.1;
COMMIT;  -- Unlocks

Page-level locks: Lock database pages (SQL Server, older systems)

Pessimistic vs Optimistic Locking

Pessimistic Locking

Lock immediately, prevent conflicts.

sql
-- Acquire lock at read time
BEGIN TRANSACTION;
  SELECT quantity FROM inventory WHERE product_id = 1 FOR UPDATE;
  -- Row locked, no one else can modify

  UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
COMMIT;

Pros: Guaranteed no conflicts Cons: Reduced concurrency, potential deadlocks Use when: High contention expected

Optimistic Locking

Don't lock, check for conflicts at update time.

sql
-- Add version column
ALTER TABLE inventory ADD COLUMN version INTEGER DEFAULT 1;

-- Read without locking
SELECT quantity, version FROM inventory WHERE product_id = 1;
-- Returns: quantity=10, version=5

-- Later: Update only if version unchanged
UPDATE inventory
SET
  quantity = quantity - 1,
  version = version + 1
WHERE product_id = 1 AND version = 5;

-- Check affected rows
IF @@ROWCOUNT = 0 THEN
  -- Version changed, conflict occurred
  -- Retry or handle error
END IF;

Pros: Better concurrency, no locks Cons: Must handle conflicts, may need retries Use when: Low contention expected

sql
-- Application-level optimistic locking pattern
max_retries = 3
for attempt in 1..max_retries:
  -- Read current version
  SELECT quantity, version FROM inventory WHERE product_id = 1

  -- Update with version check
  UPDATE inventory
  SET quantity = quantity - 1, version = version + 1
  WHERE product_id = 1 AND version = @original_version

  if rows_affected > 0:
    break  -- Success
  else:
    if attempt == max_retries:
      raise ConflictError
    -- Retry

Isolation Levels

Isolation levels control how transactions interact with each other, trading off consistency for performance.

The Four Standard Levels

Isolation LevelDirty ReadNon-Repeatable ReadPhantom ReadPerformance
READ UNCOMMITTEDYesYesYesFastest
READ COMMITTEDNoYesYesFast
REPEATABLE READNoNoYes*Slower
SERIALIZABLENoNoNoSlowest

*MySQL InnoDB prevents phantom reads even in REPEATABLE READ

Setting Isolation Level

sql
-- Session-level (affects all transactions)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Next transaction only
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION;
  -- Statements
COMMIT;

-- PostgreSQL: Per transaction
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  -- Statements
COMMIT;

-- SQL Server: Query hint
SELECT * FROM accounts WITH (SERIALIZABLE) WHERE id = 1;

Read Phenomena

Dirty Read

Reading uncommitted changes from another transaction.

sql
-- Transaction A
BEGIN;
  UPDATE accounts SET balance = 5000 WHERE id = 1;
  -- Not committed yet!

-- Transaction B (READ UNCOMMITTED)
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
  SELECT balance FROM accounts WHERE id = 1;
  -- Sees 5000 (uncommitted value)
COMMIT;

-- Transaction A
ROLLBACK;
-- Balance was never really 5000!
-- Transaction B read data that never existed

Non-Repeatable Read

Same query returns different results within transaction.

sql
-- Transaction A (READ COMMITTED)
BEGIN;
  SELECT balance FROM accounts WHERE id = 1;
  -- Returns 1000

-- Transaction B
BEGIN;
  UPDATE accounts SET balance = 2000 WHERE id = 1;
COMMIT;

-- Transaction A
  SELECT balance FROM accounts WHERE id = 1;
  -- Returns 2000 (changed!)
COMMIT;

Phantom Read

New rows appear in repeated query.

sql
-- Transaction A (REPEATABLE READ)
BEGIN;
  SELECT COUNT(*) FROM orders WHERE customer_id = 123;
  -- Returns 5

-- Transaction B
BEGIN;
  INSERT INTO orders (customer_id, total) VALUES (123, 500);
COMMIT;

-- Transaction A
  SELECT COUNT(*) FROM orders WHERE customer_id = 123;
  -- Returns 6 (new row appeared!)
COMMIT;

READ UNCOMMITTED

Lowest isolation level. Allows dirty reads.

sql
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
  SELECT * FROM large_table;
  -- Fastest, but may see uncommitted changes
COMMIT;

Use cases:

  • Approximate analytics where exact values don't matter
  • Read-only queries where dirty reads are acceptable
  • Performance-critical scenarios with acceptable trade-offs

Risks:

  • Reading data that may be rolled back
  • Inconsistent results

READ COMMITTED

Default in PostgreSQL, Oracle, SQL Server. Prevents dirty reads.

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
  SELECT balance FROM accounts WHERE id = 1;
  -- Only sees committed data

  -- Another transaction can commit changes between reads
  SELECT balance FROM accounts WHERE id = 1;
  -- Might return different value (non-repeatable read)
COMMIT;

Use cases:

  • Most general-purpose transactions
  • OLTP workloads
  • Default choice for most applications

Allows:

  • Non-repeatable reads
  • Phantom reads

REPEATABLE READ

Default in MySQL InnoDB. Prevents dirty and non-repeatable reads.

sql
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
  SELECT balance FROM accounts WHERE id = 1;  -- Returns 1000

  -- Other transactions can commit, but we won't see changes
  SELECT balance FROM accounts WHERE id = 1;  -- Still returns 1000

  -- But new rows can appear (phantoms)
  SELECT COUNT(*) FROM orders WHERE customer_id = 123;
  -- Count might change if rows inserted
COMMIT;

Use cases:

  • Reports requiring consistent snapshots
  • Multi-step calculations on same data
  • Avoiding non-repeatable reads

Allows:

  • Phantom reads (except MySQL InnoDB)

SERIALIZABLE

Highest isolation level. Prevents all phenomena.

sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
  SELECT SUM(balance) FROM accounts;
  -- Complete isolation, same result if repeated

  SELECT SUM(balance) FROM accounts;
  -- Guaranteed same result
COMMIT;

Use cases:

  • Financial reconciliation
  • Critical consistency requirements
  • End-of-period processing

Implementation:

  • PostgreSQL: Serializable Snapshot Isolation (SSI)
  • SQL Server: Range locks
  • MySQL: Gap locks (in REPEATABLE READ)

Performance impact:

  • Increased locking
  • Potential for serialization errors
  • Reduced concurrency

Rollback and Error Handling

Basic Rollback

sql
BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;
  UPDATE accounts SET balance = balance + 500 WHERE id = 2;

  -- Error occurred, undo everything
  ROLLBACK;

Conditional Rollback

sql
BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;

  -- Check business rule
  IF (SELECT balance FROM accounts WHERE id = 1) < 0 THEN
    ROLLBACK;
    -- Raise error
  ELSE
    UPDATE accounts SET balance = balance + 500 WHERE id = 2;
    COMMIT;
  END IF;

Error Handling (SQL Server)

sql
BEGIN TRANSACTION;
BEGIN TRY
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;
  UPDATE accounts SET balance = balance + 500 WHERE id = 2;

  -- Simulate error check
  IF (SELECT balance FROM accounts WHERE id = 1) < 0
    THROW 50001, 'Insufficient funds', 1;

  COMMIT TRANSACTION;
END TRY
BEGIN CATCH
  IF @@TRANCOUNT > 0
    ROLLBACK TRANSACTION;

  -- Log error
  DECLARE @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
  INSERT INTO error_log (error_msg, error_time)
  VALUES (@ErrorMessage, GETDATE());

  -- Re-raise
  THROW;
END CATCH;

Error Handling (PostgreSQL)

sql
DO $$
BEGIN
  BEGIN
    UPDATE accounts SET balance = balance - 500 WHERE id = 1;
    UPDATE accounts SET balance = balance + 500 WHERE id = 2;

    -- Check condition
    IF (SELECT balance FROM accounts WHERE id = 1) < 0 THEN
      RAISE EXCEPTION 'Insufficient funds';
    END IF;
  EXCEPTION
    WHEN OTHERS THEN
      RAISE NOTICE 'Error occurred: %', SQLERRM;
      RAISE;
  END;
END $$;

Savepoints

Savepoints create checkpoints within a transaction for partial rollback.

sql
BEGIN TRANSACTION;
  -- Step 1
  INSERT INTO customers (name, email)
  VALUES ('John Doe', 'john@example.com');
  SAVEPOINT customer_created;

  -- Step 2
  INSERT INTO addresses (customer_id, street, city)
  VALUES (LAST_INSERT_ID(), '123 Main St', 'Boston');
  SAVEPOINT address_created;

  -- Step 3 fails
  INSERT INTO orders (customer_id, total)
  VALUES (999, 100);  -- Invalid customer_id

  -- Rollback to step 2, keep customer and address
  ROLLBACK TO SAVEPOINT address_created;

  -- Continue with different operation
  INSERT INTO notes (customer_id, note)
  VALUES (LAST_INSERT_ID(), 'New customer');

COMMIT;
-- Customer, address, and note saved
-- Failed order insert was rolled back

Nested Savepoints

sql
BEGIN TRANSACTION;
  UPDATE inventory SET quantity = quantity - 10 WHERE product_id = 1;
  SAVEPOINT sp1;

  UPDATE inventory SET quantity = quantity - 5 WHERE product_id = 2;
  SAVEPOINT sp2;

  UPDATE inventory SET quantity = quantity - 3 WHERE product_id = 3;
  SAVEPOINT sp3;

  -- Error on product 3, rollback just that change
  ROLLBACK TO SAVEPOINT sp3;

  -- Continue with product 4
  UPDATE inventory SET quantity = quantity - 2 WHERE product_id = 4;

COMMIT;
-- Products 1, 2, 4 updated; product 3 unchanged

Releasing Savepoints

sql
BEGIN TRANSACTION;
  INSERT INTO orders (customer_id) VALUES (1);
  SAVEPOINT order_created;

  INSERT INTO order_items (order_id, product_id) VALUES (1, 100);

  -- No longer need savepoint
  RELEASE SAVEPOINT order_created;
  -- Can't rollback to it anymore

COMMIT;

Deadlocks

A deadlock occurs when two or more transactions wait for each other to release locks.

Deadlock Example

sql
-- Transaction A
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- Lock A
  -- Wait...
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- Wait for B
COMMIT;

-- Transaction B (concurrent)
BEGIN;
  UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- Lock B
  UPDATE accounts SET balance = balance + 50 WHERE id = 1;   -- Wait for A
COMMIT;

-- DEADLOCK! A waits for B, B waits for A

Database Response to Deadlocks

When a deadlock is detected, the database:

  1. Chooses a "victim" transaction
  2. Rolls back the victim
  3. Returns a deadlock error
  4. Allows other transaction to proceed
sql
-- One transaction gets error:
-- ERROR: deadlock detected
-- DETAIL: Process 1234 waits for ShareLock on transaction 5678

Preventing Deadlocks

1. Consistent Lock Order

Always acquire locks in the same order.

sql
-- ✅ Both transactions lock in same order (id ascending)
-- Transaction A
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- Lock 1
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- Lock 2
COMMIT;

-- Transaction B
BEGIN;
  UPDATE accounts SET balance = balance + 50 WHERE id = 1;   -- Lock 1 (waits for A)
  UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- Lock 2
COMMIT;
-- No deadlock, B waits for A to complete

2. Keep Transactions Short

sql
-- ❌ Long transaction holding locks
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  -- Complex calculation taking 30 seconds
  WAITFOR DELAY '00:00:30';
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- ✅ Short transaction
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Do complex calculations outside transaction

3. Use Lower Isolation Levels

sql
-- SERIALIZABLE increases deadlock risk
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Lower isolation = fewer locks = fewer deadlocks

4. Use NOWAIT or Timeout

sql
-- Fail immediately instead of waiting
BEGIN;
  SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
  -- Error if locked, handle gracefully
COMMIT;

-- SQL Server: Set lock timeout
SET LOCK_TIMEOUT 5000;  -- 5 seconds
BEGIN;
  UPDATE accounts SET balance = balance + 100 WHERE id = 1;
  -- Error after 5 seconds if can't acquire lock
COMMIT;

Handling Deadlocks

sql
-- Retry pattern (application code)
max_retries = 3
for attempt in 1..max_retries:
  try:
    BEGIN TRANSACTION
      UPDATE accounts SET balance = balance - 100 WHERE id = 1
      UPDATE accounts SET balance = balance + 100 WHERE id = 2
    COMMIT
    break  -- Success

  catch DeadlockError:
    ROLLBACK
    if attempt == max_retries:
      raise
    -- Wait with exponential backoff
    sleep(2^attempt * 100ms)
    -- Retry

Transaction Performance

Keep Transactions Short

sql
-- ❌ Long transaction
BEGIN;
  UPDATE large_table SET processed = 1;  -- Millions of rows
  -- Locks held for long time
COMMIT;

-- ✅ Batch processing
batch_size = 1000
WHILE EXISTS (SELECT 1 FROM large_table WHERE processed = 0 LIMIT 1):
  BEGIN;
    UPDATE large_table SET processed = 1
    WHERE id IN (
      SELECT id FROM large_table
      WHERE processed = 0
      LIMIT @batch_size
    );
  COMMIT;
  -- Small transactions, locks released frequently

Avoid Locking Inside Transactions

sql
-- ❌ External call inside transaction
BEGIN;
  UPDATE orders SET status = 'processing' WHERE id = 1;
  -- Call external payment API (slow!)
  CALL external_payment_api();
  UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;

-- ✅ External call outside transaction
BEGIN;
  UPDATE orders SET status = 'processing' WHERE id = 1;
COMMIT;

-- Call API (no locks held)
CALL external_payment_api();

BEGIN;
  UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;

Use Appropriate Isolation Level

sql
-- ❌ Unnecessary SERIALIZABLE
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
  SELECT * FROM products WHERE id = 1;  -- Simple read
COMMIT;

-- ✅ Use READ COMMITTED for simple reads
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
  SELECT * FROM products WHERE id = 1;
COMMIT;

-- ✅ Use SERIALIZABLE only when necessary
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
  -- Critical financial calculation requiring perfect consistency
  INSERT INTO daily_totals
  SELECT CURRENT_DATE, SUM(amount) FROM transactions;
COMMIT;

Read-Only Transactions

sql
-- Mark transactions as read-only for optimization
START TRANSACTION READ ONLY;
  SELECT customer_id, SUM(total)
  FROM orders
  GROUP BY customer_id;
COMMIT;

-- Benefits:
-- - No locks acquired
-- - Better query optimization
-- - Can run on read replicas

Monitor Long-Running Transactions

sql
-- PostgreSQL: Find long transactions
SELECT
  pid,
  usename,
  state,
  query_start,
  NOW() - query_start as duration,
  query
FROM pg_stat_activity
WHERE state != 'idle'
  AND NOW() - query_start > INTERVAL '1 minute'
ORDER BY duration DESC;

-- SQL Server: Find blocked transactions
SELECT
  blocking_session_id,
  session_id,
  wait_type,
  wait_time / 1000.0 as wait_time_sec,
  wait_resource
FROM sys.dm_exec_requests
WHERE blocking_session_id > 0;

-- Kill long transaction (use carefully!)
-- PostgreSQL
SELECT pg_terminate_backend(pid);

-- SQL Server
KILL session_id;

Platform-Specific Notes

PostgreSQL

Transaction Syntax:

sql
BEGIN;  -- or START TRANSACTION
  -- statements
COMMIT;  -- or ROLLBACK

Default Isolation: READ COMMITTED

MVCC: Uses Multi-Version Concurrency Control for high concurrency

Serializable Isolation:

sql
-- Uses Serializable Snapshot Isolation (SSI)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  SELECT SUM(balance) FROM accounts;
  -- May fail with serialization error if conflict detected
COMMIT;

Advisory Locks (application-level):

sql
-- Acquire lock
SELECT pg_advisory_lock(12345);

-- Try lock (non-blocking)
SELECT pg_try_advisory_lock(12345);

-- Release lock
SELECT pg_advisory_unlock(12345);

Transaction ID:

sql
SELECT txid_current();  -- Get current transaction ID

Prepared Transactions (two-phase commit):

sql
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'transaction_123';

-- Later: commit or rollback
COMMIT PREPARED 'transaction_123';
-- or
ROLLBACK PREPARED 'transaction_123';
MySQL

Transaction Syntax:

sql
START TRANSACTION;  -- or BEGIN
  -- statements
COMMIT;  -- or ROLLBACK

Default Isolation: REPEATABLE READ (InnoDB)

Storage Engines:

  • InnoDB: Full ACID support, row-level locking
  • MyISAM: No transaction support, table-level locking

Locking Reads:

sql
-- Lock rows for update
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

-- Shared lock
SELECT * FROM accounts WHERE id = 1 FOR SHARE;

-- Lock in share mode (older syntax)
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;

-- Skip locked rows
SELECT * FROM jobs WHERE status = 'pending'
LIMIT 10 FOR UPDATE SKIP LOCKED;

Autocommit:

sql
-- Check autocommit
SELECT @@autocommit;

-- Disable
SET autocommit = 0;

-- Enable
SET autocommit = 1;

Consistent Snapshot:

sql
-- Start transaction with snapshot
START TRANSACTION WITH CONSISTENT SNAPSHOT;
-- All reads see snapshot at transaction start

Gap Locks (REPEATABLE READ):

sql
-- Locks gaps between index entries to prevent phantoms
BEGIN;
  SELECT * FROM orders
  WHERE customer_id BETWEEN 100 AND 200
  FOR UPDATE;
  -- Locks rows AND gaps, prevents inserts in range
COMMIT;
SQL Server

Transaction Syntax:

sql
BEGIN TRANSACTION;  -- or BEGIN TRAN
  -- statements
COMMIT TRANSACTION;  -- or ROLLBACK TRANSACTION

Default Isolation: READ COMMITTED

Named Transactions:

sql
BEGIN TRANSACTION transfer_funds;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT TRANSACTION transfer_funds;

Isolation Levels:

sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Snapshot isolation (requires configuration)
ALTER DATABASE mydb SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;

-- Read committed snapshot (MVCC-like)
ALTER DATABASE mydb SET READ_COMMITTED_SNAPSHOT ON;

Lock Hints:

sql
-- Update lock
SELECT * FROM accounts WITH (UPDLOCK) WHERE id = 1;

-- No lock (dirty read)
SELECT * FROM accounts WITH (NOLOCK);

-- Row lock
SELECT * FROM accounts WITH (ROWLOCK) WHERE id = 1;

-- Table lock
SELECT * FROM accounts WITH (TABLOCK);

-- Hold lock until transaction end
SELECT * FROM accounts WITH (HOLDLOCK) WHERE id = 1;

Savepoints:

sql
BEGIN TRANSACTION;
  SAVE TRANSACTION savepoint1;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  ROLLBACK TRANSACTION savepoint1;
COMMIT TRANSACTION;

Error Handling:

sql
BEGIN TRANSACTION;
BEGIN TRY
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
  COMMIT TRANSACTION;
END TRY
BEGIN CATCH
  IF @@TRANCOUNT > 0
    ROLLBACK TRANSACTION;
  THROW;
END CATCH;
Oracle

Transaction Syntax:

sql
-- No explicit BEGIN (transactions start automatically)
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;  -- or ROLLBACK

Default Isolation: READ COMMITTED

Isolation Levels:

sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET TRANSACTION READ ONLY;

Locking:

sql
-- Lock for update
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

-- No wait
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- Wait specified time
SELECT * FROM accounts WHERE id = 1 FOR UPDATE WAIT 5;

-- Skip locked
SELECT * FROM jobs WHERE status = 'pending'
FOR UPDATE SKIP LOCKED;

Savepoints:

sql
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT sp1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ROLLBACK TO SAVEPOINT sp1;
COMMIT;

Flashback Query (read historical data):

sql
-- Query as of timestamp
SELECT * FROM accounts
AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '5' MINUTE)
WHERE id = 1;

-- Query as of SCN
SELECT * FROM accounts AS OF SCN 123456 WHERE id = 1;

Autonomous Transactions:

sql
CREATE OR REPLACE PROCEDURE log_audit(msg VARCHAR2) AS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
  INSERT INTO audit_log (message) VALUES (msg);
  COMMIT;  -- Independent of calling transaction
END;
SQLite

Transaction Syntax:

sql
BEGIN;  -- or BEGIN TRANSACTION
  -- statements
COMMIT;  -- or ROLLBACK

Default Isolation: SERIALIZABLE

Transaction Types:

sql
-- Deferred (default): Lock on first read/write
BEGIN DEFERRED TRANSACTION;

-- Immediate: Write lock immediately
BEGIN IMMEDIATE TRANSACTION;

-- Exclusive: Exclusive lock immediately
BEGIN EXCLUSIVE TRANSACTION;

Locking:

  • File-level locking
  • One writer at a time
  • Multiple concurrent readers (except exclusive lock)

WAL Mode (better concurrency):

sql
PRAGMA journal_mode = WAL;
-- Readers don't block writers
-- Writers don't block readers
-- Writers still block writers

Busy Timeout:

sql
-- Wait up to 5 seconds for lock
PRAGMA busy_timeout = 5000;

Savepoints:

sql
BEGIN;
  SAVEPOINT sp1;
  INSERT INTO logs (message) VALUES ('Event 1');
  ROLLBACK TO sp1;
  INSERT INTO logs (message) VALUES ('Event 2');
COMMIT;

Limitations:

  • No row-level locking
  • Limited concurrent writes
  • No explicit isolation level control
BigQuery

Transaction Support: Limited (Beta)

Syntax:

sql
BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT TRANSACTION;

Limitations:

  • Single region only
  • Max 20,000 DML statements per transaction
  • 6 hour time limit
  • No savepoints
  • No explicit isolation level control

Automatic Snapshot Consistency:

sql
-- Queries automatically see consistent snapshot
SELECT * FROM large_table WHERE date = CURRENT_DATE();
-- Consistent view even during concurrent inserts

Time Travel:

sql
-- Query historical data (up to 7 days)
SELECT * FROM `project.dataset.table`
FOR SYSTEM_TIME AS OF
  TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR);
Snowflake

Transaction Syntax:

sql
BEGIN;  -- or BEGIN TRANSACTION
  -- statements
COMMIT;  -- or ROLLBACK

Default Isolation: Snapshot isolation (READ COMMITTED with MVCC)

MVCC Benefits:

  • Reads never block writes
  • Writes never block reads
  • No dirty reads
  • Snapshot isolation by default

Multi-Statement Transactions:

sql
BEGIN TRANSACTION;
  UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
  INSERT INTO sales (product_id, quantity) VALUES (1, 1);
COMMIT;

Zero-Copy Cloning:

sql
-- Instant ACID-compliant copy
CREATE TABLE accounts_backup CLONE accounts;
-- No storage duplication initially

Time Travel:

sql
-- Query historical data
SELECT * FROM accounts AT(OFFSET => -60*5);  -- 5 minutes ago

-- Clone from past
CREATE TABLE accounts_restore CLONE accounts
AT(TIMESTAMP => '2024-01-15 14:30:00'::TIMESTAMP);

-- Undrop
UNDROP TABLE accounts;

Limitations:

  • No savepoints
  • No explicit isolation level control
  • Transactions limited to explicit BEGIN/COMMIT block
DuckDB

Transaction Syntax:

sql
BEGIN;  -- or START TRANSACTION
  -- statements
COMMIT;  -- or ROLLBACK

Default Isolation: SERIALIZABLE (MVCC with snapshot isolation)

ACID Support: Full ACID compliance with WAL

Concurrency:

  • Multiple concurrent readers
  • Single writer at a time
  • Readers don't block writers
  • Writers don't block readers (different transactions)

Savepoints:

sql
BEGIN;
  INSERT INTO logs (message) VALUES ('Event 1');
  SAVEPOINT sp1;
  INSERT INTO logs (message) VALUES ('Event 2');
  ROLLBACK TO sp1;
COMMIT;

Checkpointing:

sql
-- Force checkpoint (flush WAL)
CHECKPOINT;

-- Configure WAL size
SET wal_autocheckpoint = 16777216;  -- 16 MB

In-Memory vs Persistent:

sql
-- In-memory (no durability)
duckdb :memory:

-- Persistent (full ACID)
duckdb mydb.duckdb

Best Practices

1. Keep Transactions Short and Focused

sql
-- ✅ Good: Short, focused transaction
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- ❌ Bad: Long transaction with unrelated operations
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE customer_stats SET last_transaction = NOW();
  INSERT INTO audit_log (message) VALUES ('Transfer');
  UPDATE system_config SET last_update = NOW();
  -- Sleep or external call
COMMIT;

2. Always Handle Errors

sql
-- ✅ Good: Explicit error handling
BEGIN;
  BEGIN TRY
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
    UPDATE accounts SET balance = balance + 100 WHERE id = 2;
    COMMIT;
  END TRY
  BEGIN CATCH
    ROLLBACK;
    -- Log error and re-raise
    THROW;
  END CATCH

3. Use Appropriate Isolation Levels

sql
-- ✅ Default (READ COMMITTED) for most operations
BEGIN;
  INSERT INTO orders (customer_id, total) VALUES (1, 500);
COMMIT;

-- ✅ REPEATABLE READ for consistent reports
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
  SELECT customer_id, SUM(total) FROM orders GROUP BY customer_id;
  -- Multi-step report with consistent data
COMMIT;

-- ✅ SERIALIZABLE for critical operations
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
  -- Financial reconciliation
COMMIT;

4. Acquire Locks in Consistent Order

sql
-- ✅ Always lock in same order (prevents deadlocks)
-- Order by ID ascending
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(@id1, @id2);
  UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(@id1, @id2);
COMMIT;

5. Use Savepoints for Complex Transactions

sql
-- ✅ Good: Savepoints for logical units
BEGIN;
  INSERT INTO customers (name) VALUES ('John');
  SAVEPOINT customer_created;

  BEGIN TRY
    INSERT INTO premium_accounts (customer_id) VALUES (LAST_INSERT_ID());
  END TRY
  BEGIN CATCH
    ROLLBACK TO SAVEPOINT customer_created;
    -- Create standard account instead
    INSERT INTO standard_accounts (customer_id) VALUES (LAST_INSERT_ID());
  END CATCH

COMMIT;

6. Avoid User Interaction in Transactions

sql
-- ❌ Bad: Transaction open during user interaction
BEGIN;
  SELECT * FROM inventory WHERE id = 1 FOR UPDATE;
  -- Wait for user to confirm (locks held!)
COMMIT;

-- ✅ Good: No locks during user interaction
-- Read without lock
SELECT quantity, version FROM inventory WHERE id = 1;

-- User confirms in UI (no transaction)

-- Quick transaction when ready
BEGIN;
  UPDATE inventory SET quantity = quantity - 1, version = version + 1
  WHERE id = 1 AND version = @original_version;
COMMIT;

7. Monitor and Set Timeouts

sql
-- PostgreSQL
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '5min';

-- SQL Server
SET LOCK_TIMEOUT 10000;  -- 10 seconds

-- Prevent runaway transactions

8. Batch Large Operations

sql
-- ✅ Process in batches
batch_size = 1000
WHILE EXISTS (SELECT 1 FROM large_table WHERE processed = 0 LIMIT 1):
  BEGIN;
    UPDATE large_table SET processed = 1
    WHERE id IN (
      SELECT id FROM large_table
      WHERE processed = 0
      LIMIT @batch_size
    );
  COMMIT;
  -- Brief pause
  SLEEP(0.1);

Common Pitfalls

1. Forgetting COMMIT or ROLLBACK

sql
-- ❌ Transaction left open
BEGIN;
  UPDATE accounts SET balance = balance + 100 WHERE id = 1;
  -- Connection drops, transaction hangs

-- ✅ Always ensure cleanup
try:
  BEGIN;
  UPDATE accounts SET balance = balance + 100 WHERE id = 1;
  COMMIT;
catch:
  ROLLBACK;
finally:
  // Close connection

2. Implicit Commits (MySQL)

sql
-- ❌ DDL causes implicit commit
BEGIN;
  UPDATE accounts SET balance = balance + 100 WHERE id = 1;
  CREATE TABLE temp (id INT);  -- Implicit COMMIT!
  UPDATE accounts SET balance = balance - 100 WHERE id = 2;
ROLLBACK;  -- Only rolls back second UPDATE!

-- Operations causing implicit commits:
-- DDL: CREATE, ALTER, DROP, TRUNCATE
-- Account: CREATE USER, GRANT, REVOKE
-- LOCK TABLES, UNLOCK TABLES

3. Lost Updates

sql
-- ❌ Lost update (read-then-update)
-- Transaction A
SELECT balance FROM accounts WHERE id = 1;  -- Returns 1000
-- Calculate: 1000 - 100 = 900

-- Transaction B
SELECT balance FROM accounts WHERE id = 1;  -- Returns 1000
-- Calculate: 1000 - 50 = 950
UPDATE accounts SET balance = 950 WHERE id = 1;
COMMIT;

-- Transaction A continues
UPDATE accounts SET balance = 900 WHERE id = 1;  -- Overwrites B's change!
COMMIT;
-- Lost Transaction B's update

-- ✅ Solution: Atomic operation
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

-- ✅ Or: Lock for update
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = ... WHERE id = 1;

4. Unnecessary SERIALIZABLE

sql
-- ❌ Overkill
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
  SELECT * FROM products WHERE id = 1;  -- Simple read
COMMIT;

-- ✅ Use lower isolation when possible
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
  SELECT * FROM products WHERE id = 1;
COMMIT;

5. Long Transactions

sql
-- ❌ Long-running transaction
BEGIN;
  -- Process millions of rows
  UPDATE huge_table SET processed = 1;
  -- Locks held for minutes/hours
COMMIT;

-- ✅ Batch processing
WHILE EXISTS (SELECT 1 FROM huge_table WHERE processed = 0 LIMIT 1):
  BEGIN;
    UPDATE huge_table SET processed = 1
    WHERE id IN (SELECT id FROM huge_table WHERE processed = 0 LIMIT 1000);
  COMMIT;

See Also

  • ACID Properties - Detailed coverage of Atomicity, Consistency, Isolation, Durability
  • Indexes - Indexes impact transaction performance and locking
  • Query Performance - Optimize queries within transactions
  • Joins - Efficient joins reduce transaction time
  • Basics - SQL fundamentals including basic transaction syntax

Released under the MIT License.