Transactions Deep Dive

intermediate transactions acid commit rollback savepoint

A transaction is a group of SQL operations that are treated as a single unit of work. Either ALL of them succeed, or NONE of them do. There’s no in-between.

Think of it like a bank transfer. If we’re moving $500 from account A to account B, we need two operations: subtract from A and add to B. If the system crashes after subtracting but before adding, the money just vanishes. Transactions prevent this by wrapping both operations together.

The Basics: BEGIN, COMMIT, ROLLBACK

-- Start a transaction
BEGIN;

-- Deduct from sender
UPDATE accounts SET balance = balance - 500 WHERE id = 1;

-- Add to receiver
UPDATE accounts SET balance = balance + 500 WHERE id = 2;

-- Everything looks good? Make it permanent
COMMIT;

If something goes wrong at any point, we can undo everything:

BEGIN;

UPDATE accounts SET balance = balance - 500 WHERE id = 1;

-- Oops, the receiver account doesn't exist!
-- Undo everything
ROLLBACK;
-- The sender's balance is restored to what it was before

Auto-Commit Mode

By default, most databases run in auto-commit mode. This means every single SQL statement is automatically wrapped in its own transaction and committed immediately.

-- These two statements are independent transactions
UPDATE accounts SET balance = balance - 500 WHERE id = 1;
-- ^ auto-committed immediately

UPDATE accounts SET balance = balance + 500 WHERE id = 2;
-- ^ auto-committed immediately

-- If the second one fails, the first is already committed!
-- That's why we need explicit transactions for multi-step operations.

Savepoints

Sometimes we want to undo part of a transaction without rolling back the whole thing. That’s what savepoints are for.

Think of it like saving a game. If we mess up, we can reload from the last save point instead of starting over.

BEGIN;

INSERT INTO orders (user_id, total) VALUES (1, 100.00);

SAVEPOINT before_items;

INSERT INTO order_items (order_id, product_id, qty) VALUES (1, 42, 2);
-- Oops, product 42 is out of stock

ROLLBACK TO before_items;
-- The order is still there, but the item insert is undone

-- Try a different product
INSERT INTO order_items (order_id, product_id, qty) VALUES (1, 43, 2);

COMMIT;  -- commits the order + the second item
Transaction Lifecycle
BEGIN
SQL operations
SAVEPOINT (optional)
COMMIT ✓
or
ROLLBACK ✗

Common Mistakes

1. Forgetting to COMMIT

If we start a transaction and forget to commit, the locks are held until we either commit, rollback, or the connection times out. Other queries waiting for those rows will be blocked.

BEGIN;
UPDATE products SET price = 29.99 WHERE id = 1;
-- Developer walks away for lunch...
-- This row is now locked for everyone else!

-- Always commit or rollback when done
COMMIT;

2. Long-Running Transactions

Keeping a transaction open for a long time is a recipe for trouble:

  • Locks are held the entire time, blocking other users
  • In PostgreSQL, dead tuples can’t be cleaned up by VACUUM
  • Memory usage increases because the database keeps undo information
-- Bad: doing slow processing inside a transaction
BEGIN;
SELECT * FROM orders WHERE status = 'pending';
-- ... application processes orders for 5 minutes ...
UPDATE orders SET status = 'processed' WHERE id IN (...);
COMMIT;

-- Better: read first, process outside the transaction, then update
SELECT * FROM orders WHERE status = 'pending';
-- ... application processes orders ...
BEGIN;
UPDATE orders SET status = 'processed' WHERE id IN (...);
COMMIT;  -- transaction lasts milliseconds, not minutes

3. Nested Transactions

Most databases don’t support true nested transactions. In PostgreSQL, if we call BEGIN inside an existing transaction, it just ignores it (with a warning). Use savepoints instead:

BEGIN;

-- This does NOT create a nested transaction
-- BEGIN;  -- WARNING: there is already a transaction in progress

-- Use savepoints for nested behavior
SAVEPOINT sp1;
-- do stuff
RELEASE SAVEPOINT sp1;  -- like a mini-commit

COMMIT;

Transaction in Application Code

Here’s how transactions typically look in application code:

-- PostgreSQL with a connection pool (pseudocode)
-- const client = await pool.connect();
-- try {
--     await client.query('BEGIN');
--     await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [500, 1]);
--     await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [500, 2]);
--     await client.query('COMMIT');
-- } catch (e) {
--     await client.query('ROLLBACK');
--     throw e;
-- } finally {
--     client.release();
-- }

In simple language, transactions are our safety net for multi-step operations. Wrap related operations in BEGIN/COMMIT, use savepoints for partial rollbacks, and keep transactions as short as possible. A forgotten COMMIT can bring an entire application to its knees.