SQL Transactions — Multi-Statement Operations, ACID Guarantees, and Isolation Levels
Status: Active | Last Updated: 2026-08-29 Category: SQL — Transactions Prerequisites: SQL Schema Tags: sql, transactions, acid, begin, commit, rollback, isolation-levels, dirty-read, read-committed, repeatable-read, serializable, migration, prisma Estimated Time: 4-5 hours (Self-paced, includes lab time)
Summary
A transaction is a group of SQL statements that execute as a single atomic unit: either all of them succeed and are made permanent (
COMMIT), or all of them are undone (ROLLBACK). Transactions provide ACID guarantees — Atomicity, Consistency, Isolation, and Durability — that make relational databases trustworthy for financial, inventory, and any data where partial updates are worse than no updates.
What You'll Learn (Core Competencies)
- Explain and apply the four ACID properties in database operations
- Write transactional SQL using
BEGIN,COMMIT, andROLLBACK - Choose the correct isolation level for concurrent read/write workloads
- Write safe database migrations that update schema and data atomically
- Detect and prevent common transaction pitfalls (lost updates, phantom reads, deadlocks)
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Why Transactions Matter
Without transactions, a multi-step operation can partially complete:
Bad scenario (no transaction):
UPDATE accounts SET balance = balance - 100 WHERE id = 1; ← succeeds
UPDATE accounts SET balance = balance + 100 WHERE id = 2; ← FAILS (FK violation)
Result: Alice lost $100. Bob never received it. The database is inconsistent.
Good scenario (transaction):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; ← only if both succeed
Result: Both updates happen atomically. If step 2 fails, ROLLBACK undoes step 1.
ACID Properties
| Property | What it guarantees | Failure mode without it |
|---|---|---|
| Atomicity | All-or-nothing execution | Partial updates |
| Consistency | Database moves valid-state to valid-state | Constraint violations persist |
| Isolation | Concurrent xacts don't interfere | Dirty reads, lost updates |
| Durability | Committed data survives crashes | Data loss on power failure |
The Transaction Lifecycle
BEGIN (start transaction)
├─ Statement 1
├─ Statement 2
└─ ...
├─ COMMIT ← all changes permanent
OR
└─ ROLLBACK ← all changes undone
Outside a transaction, every statement auto-commits (SQLite, MySQL auto-commit; PostgreSQL also defaults to auto-commit in most drivers).
2. Deep Dive & Implementation
BEGIN, COMMIT, ROLLBACK
BEGIN;
-- Money transfer: debit Alice, credit Bob
UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;
UPDATE accounts SET balance = balance + 100.00 WHERE id = 2;
-- Verify no overdraft before committing
SELECT balance FROM accounts WHERE id = 1;
-- If balance >= 0, proceed; otherwise ROLLBACK
COMMIT; -- make changes permanent
-- ROLLBACK; -- undo all changes (uncomment to test rollback)
In Prisma:
// Prisma wraps everything in a transaction automatically if the function throws
await prisma.$transaction(async (tx) => {
await tx.account.update({
where: { id: 1 },
data: { balance: { decrement: 100 } }
});
await tx.account.update({
where: { id: 2 },
data: { balance: { increment: 100 } }
});
// If this function throws, both updates are rolled back
});
Prisma also supports interactive transactions with prisma.$transaction(async (tx) => {...}).
Isolation Levels
Concurrent transactions can interfere with each other. Isolation levels control how much interference is allowed:
| Level | Dirty reads | Non-repeatable reads | Phantom reads | Performance |
|---|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible | Fastest |
| READ COMMITTED | Prevented | Possible | Possible | Good |
| REPEATABLE READ | Prevented | Prevented | Possible (MySQL) | Moderate |
| SERIALIZABLE | Prevented | Prevented | Prevented | Slowest |
PostgreSQL default: READ COMMITTED
MySQL (InnoDB) default: REPEATABLE READ
-- Set isolation level for a session
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
-- ... queries ...
COMMIT;
-- SQLite: always SERIALIZABLE (no configurable levels)
Setting Savepoints
Savepoints allow partial rollback within a transaction:
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
SAVEPOINT sp1; -- named checkpoint
UPDATE accounts SET balance = balance - 30 WHERE id = 1;
-- Oops, that second debit is wrong — undo just this part:
ROLLBACK TO SAVEPOINT sp1; -- reverts the second UPDATE, keeps the first
-- Continue with correct operation:
UPDATE accounts SET balance = balance - 20 WHERE id = 1;
COMMIT; -- commits all un-rolled-back changes since BEGIN
Database Migrations
Migrations change the schema atomically. A good migration:
-- Good migration: wraps schema + data changes in a transaction
BEGIN;
-- Step 1: Add new column with a default
ALTER TABLE orders ADD COLUMN shipped_at TEXT;
-- Step 2: Populate the new column from existing data
UPDATE orders SET shipped_at = created_at WHERE status = 'shipped';
-- Step 3: Add a NOT NULL constraint (requires the column to be populated first)
-- Some DBs require this as a separate step
COMMIT;
With Prisma Migrate, migrations are generated as SQL files — inspect them before applying to production.
Guided Checkpoint
Verify atomicity and rollback behavior using SQLite:
sqlite3 :memory: -header -column "
CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT, balance REAL);
-- Initial data
INSERT INTO accounts VALUES (1, 'Alice', 1000.00), (2, 'Bob', 500.00);
-- Test 1: Commit (all changes permanent)
SELECT '=== Test 1: COMMIT — balances after transfer ===' AS test;
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
SELECT name, balance FROM accounts ORDER BY id;
-- Reset for test 2
UPDATE accounts SET balance = 1000.00 WHERE id = 1;
UPDATE accounts SET balance = 500.00 WHERE id = 2;
-- Test 2: Rollback (no changes)
SELECT '=== Test 2: ROLLBACK — balances after rollback ===' AS test;
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Simulate an error condition: force rollback
ROLLBACK;
SELECT name, balance FROM accounts ORDER BY id;
-- Verify: balances should be unchanged from initial state
SELECT '=== Balance check (should match initial: Alice=1000, Bob=500) ===' AS test;
SELECT
CASE WHEN name='Alice' AND balance=1000.00 AND name='Bob' AND balance=500.00
THEN 'PASS: atomicity confirmed'
ELSE 'FAIL: transaction leaked' END AS result
FROM accounts;
"
Expected: After COMMIT, Alice=900, Bob=600. After ROLLBACK, Alice=1000, Bob=500 (unchanged). The final check prints PASS.
3. Anti-Patterns & Common Pitfalls
Anti-Pattern 1: Forgetting to Commit in Long-Running Transactions
Open transactions hold locks and prevent vacuum/cleanup. Long-running transactions also risk snapshot staleness.
-- Bad: BEGIN but no COMMIT/ROLLBACK — connection holds locks indefinitely
BEGIN;
-- long-running operation...
-- connection drops here — transaction is rolled back but locks may persist until timeout
Fix: Always pair BEGIN with COMMIT or ROLLBACK in a finally block at the application layer.
Anti-Pattern 2: Using READ UNCOMMITTED for Financial Data
READ UNCOMMITTED allows dirty reads — seeing uncommitted changes from other transactions. For money transfers, this can mean reading a balance that hasn't been committed, leading to incorrect decisions.
-- Bad for financial data
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- Good: use at least READ COMMITTED, SERIALIZABLE for critical data
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Anti-Pattern 3: Lost Updates (Concurrent Writes Without Isolation)
Two transactions read the same balance, both add 100, both write — one update is silently lost.
-- T1: read balance=1000
-- T2: read balance=1000
-- T1: write balance=1100 (read-1100+100-write)
-- T2: write balance=1100 ← LOST UPDATE: T1's change is overwritten
-- Fix: use SERIALIZABLE or SELECT ... FOR UPDATE (row-level lock)
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- locks the row
-- now T2 waits until T1 commits
COMMIT;
Anti-Pattern 4: Non-Atomic Migrations
Splitting a schema change into multiple non-transactional statements means partial failure.
-- Bad: if step 2 fails, step 1 is already committed
ALTER TABLE orders ADD COLUMN new_col TEXT;
UPDATE orders SET new_col = 'default'; -- fails — but column exists
ALTER TABLE orders ALTER COLUMN new_col SET NOT NULL; -- never reached
-- Good: wrap in a transaction
BEGIN;
ALTER TABLE orders ADD COLUMN new_col TEXT;
UPDATE orders SET new_col = 'default';
-- validation...
COMMIT;
Note: Some DDL statements (e.g., ALTER TABLE DROP COLUMN in PostgreSQL) cannot be run inside a transaction. Check your database's documentation.
4. Independent Challenge
Design and test a multi-step transaction for an inventory and order system.
Schema:
CREATE TABLE inventory (product_id INTEGER PRIMARY KEY, name TEXT, stock INTEGER);
CREATE TABLE orders (id INTEGER PRIMARY KEY, product_id INTEGER, qty INTEGER, status TEXT);
CREATE TABLE balance (account_id INTEGER PRIMARY KEY, amount REAL);
Write a transaction that:
- Checks that
inventory.stock >= order_qtyfor a given product. - If sufficient stock: decrement inventory, insert an order record with status
confirmed, and credit the account with the total price (stock × unit_price). - If insufficient stock: insert an order with status
rejected, and do not modify inventory or balance. - Use savepoints to handle partial failures within the transaction.
- Demonstrate that a
ROLLBACKin the application layer correctly restores the initial state.
Save as challenge_inventory_transaction.sql. Include both a "success" and "insufficient stock" test run.
5. Consolidation & Key Invariants
Bullet-list summary of the must-remember rules.
- Every transaction ends with
COMMITorROLLBACK— never leave a transaction open. - ACID guarantees apply only within a transaction — auto-committed single statements have no atomicity across statements.
READ COMMITTED(PostgreSQL default) prevents dirty reads but allows non-repeatable reads — the same row read twice in one transaction may differ.- Use
SERIALIZABLEfor financial and inventory operations where lost updates are unacceptable. SELECT ... FOR UPDATElocks rows to prevent concurrent transactions from reading stale data during updates.- Savepoints enable partial rollback within a long transaction — useful for optional sub-operations.
- Migrations should be transactional where the database supports DDL in transactions (PostgreSQL: yes; MySQL: partial; SQLite: yes).
6. Next Steps
- SQL Schema — The constraints and foreign keys that transactions protect. Prerequisite: this lesson.
- SQL Aggregation — Aggregate data from normalized tables within transactional boundaries.
- Backend overview — See how Prisma's
$transactionAPI wraps PostgreSQL transactions in the fogserv.cloud stack. - Languages overview — See how TypeScript application code calls into these SQL transactions via Prisma.
Change Log
- 2026-08-29: Migrated to 6-section canonical template (2026-08-29)
- 2026-08-29: Expanded from stub with ACID deep-dive, isolation levels, savepoints, migrations, anti-patterns, and guided checkpoint