Skip to main content

ACID and Transaction Control

Learning Focus

Use this lesson to group changes into transactions in PostgreSQL so multi-step operations succeed or fail together, and to apply safe transaction control patterns (savepoints, timeouts, and verification queries).

Concept Overview

A transaction is a set of SQL statements treated as a single unit of work.

If the transaction commits, all changes become visible. If it rolls back, none of the changes remain.

PostgreSQL transactions follow ACID:

  • Atomicity: all-or-nothing
  • Consistency: constraints and invariants remain valid
  • Isolation: concurrent transactions interact predictably (MVCC)
  • Durability: committed changes persist (WAL)

Why is it important?

  • Correctness: prevent partial updates (e.g., debit succeeds, credit fails)
  • Safety: enforce invariants under concurrent writers
  • Recoverability: failures don’t corrupt your logical state
  • Operational predictability: short transactions reduce lock contention and vacuum pressure

Where does it fit?

Transactions are the foundation for:

  • multi-table writes (orders + items + inventory)
  • migrations/backfills
  • locking patterns (SELECT ... FOR UPDATE)
  • isolation level choices (READ COMMITTED, REPEATABLE READ, SERIALIZABLE)

Syntax & Rules

Core Syntax

BEGIN;

-- statements

COMMIT;
-- or
ROLLBACK;

Savepoints (partial rollback inside a transaction):

BEGIN;
SAVEPOINT sp1;

-- risky statements

ROLLBACK TO SAVEPOINT sp1;
RELEASE SAVEPOINT sp1;

COMMIT;

Available Options / Parameters

StatementExampleMeaning / Notes
BeginBEGINstart a transaction (same as START TRANSACTION)
CommitCOMMITpersist changes
RollbackROLLBACKdiscard changes
SavepointSAVEPOINT screate a checkpoint
Rollback to savepointROLLBACK TO SAVEPOINT sundo to checkpoint
Release savepointRELEASE SAVEPOINT sremove the checkpoint
Isolation levelSET TRANSACTION ISOLATION LEVEL SERIALIZABLEaffects visibility/serialization behavior
Lock timeoutSET LOCAL lock_timeout = '2s'abort if lock acquisition takes too long
Statement timeoutSET LOCAL statement_timeout = '30s'abort long queries in this transaction

Key Rules and Considerations

  • By default, PostgreSQL runs each statement in its own implicit transaction (autocommit behavior in most clients).
  • Once an error occurs in a transaction, PostgreSQL marks the transaction as failed; you must ROLLBACK (or roll back to a savepoint) before continuing.
  • Most PostgreSQL DDL is transactional (can be rolled back). Some operations are not allowed inside transactions (e.g., CREATE DATABASE, VACUUM, CREATE INDEX CONCURRENTLY).
  • Keep transactions short: long transactions keep old row versions alive and can block vacuum from reclaiming space.

Step-by-Step Examples

Example 1: Basic Commit vs Rollback (Beginner)

Setup:

DROP TABLE IF EXISTS accounts;

CREATE TABLE accounts (
account_id bigint PRIMARY KEY,
balance numeric(12,2) NOT NULL CHECK (balance >= 0)
);

INSERT INTO accounts (account_id, balance) VALUES
(1, 5000.00),
(2, 3000.00);

SELECT account_id, balance
FROM accounts
ORDER BY account_id;

Expected output:

DROP TABLE
CREATE TABLE
INSERT 0 2
account_id | balance
------------+---------
1 | 5000.00
2 | 3000.00
(2 rows)

Rollback example:

BEGIN;

UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;

ROLLBACK;

SELECT account_id, balance
FROM accounts
ORDER BY account_id;

Expected output:

BEGIN
UPDATE 1
UPDATE 1
ROLLBACK
account_id | balance
------------+---------
1 | 5000.00
2 | 3000.00
(2 rows)

Explanation:

  • The updates happened, but ROLLBACK discarded them.

Example 2: Use SAVEPOINT to Recover from a Failed Step (Intermediate)

Setup:

DROP TABLE IF EXISTS emails;

CREATE TABLE emails (
email text PRIMARY KEY
);

INSERT INTO emails (email) VALUES ('a@example.com');

Transaction with a safe fallback:

BEGIN;

SAVEPOINT s1;

-- This will fail (duplicate primary key)
INSERT INTO emails (email) VALUES ('a@example.com');

-- Recover without losing the whole transaction
ROLLBACK TO SAVEPOINT s1;

-- Continue doing useful work
INSERT INTO emails (email) VALUES ('b@example.com');

COMMIT;

SELECT email FROM emails ORDER BY email;

Expected output:

DROP TABLE
CREATE TABLE
INSERT 0 1
BEGIN
SAVEPOINT
ERROR: duplicate key value violates unique constraint "emails_pkey"
ROLLBACK
INSERT 0 1
COMMIT
email
---------------
a@example.com
b@example.com
(2 rows)

Explanation:

  • Without a savepoint, the first error would force a full ROLLBACK.
  • Rolling back to the savepoint clears the error state and lets you continue.

Example 3: Multi-Table Write with Verification and RETURNING (Intermediate)

DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(order_id),
sku text NOT NULL,
qty integer NOT NULL CHECK (qty > 0)
);

BEGIN;

WITH new_order AS (
INSERT INTO orders (status)
VALUES ('pending')
RETURNING order_id
)
INSERT INTO order_items (order_id, sku, qty)
SELECT order_id, 'A1', 2 FROM new_order
UNION ALL
SELECT order_id, 'B2', 1 FROM new_order;

SELECT o.order_id, o.status, COUNT(*) AS item_count
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
GROUP BY o.order_id, o.status
ORDER BY o.order_id;

COMMIT;

Expected output (example):

DROP TABLE
DROP TABLE
CREATE TABLE
CREATE TABLE
BEGIN
INSERT 0 2
order_id | status | item_count
----------+---------+-----------
1 | pending | 2
(1 row)

COMMIT

Explanation:

  • RETURNING lets you wire multi-step inserts safely.
  • The verification SELECT inside the transaction lets you confirm results before committing.

Example 4: Transaction-Scoped Timeouts (Advanced)

BEGIN;
SET LOCAL lock_timeout = '1s';
SET LOCAL statement_timeout = '5s';

-- your statements here

COMMIT;

Expected output:

BEGIN
SET
SET
COMMIT

Explanation:

  • SET LOCAL applies only for the current transaction.
  • Timeouts are a practical guardrail against unexpected lock waits.

Practical Use Cases

1) Money Transfers / Ledger Updates

Context: debit + credit must happen together.

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;

2) Backfills in Batches

Context: update a large table safely.

BEGIN;
SET LOCAL statement_timeout = '30s';
UPDATE big_table SET new_col = ... WHERE new_col IS NULL AND id BETWEEN 1 AND 100000;
COMMIT;

3) Multi-Table “Create Order” Workflow

Context: ensure order + items are consistent.

BEGIN;
-- insert order
-- insert items
-- verify
COMMIT;

4) Safe “Try Step” Patterns

Context: attempt a risky step but keep going.

SAVEPOINT s;
-- risky step
ROLLBACK TO SAVEPOINT s;

Common Mistakes & Troubleshooting

Mistake 1: Leaving Transactions Open (“idle in transaction”)

Wrong pattern:

BEGIN;
-- user goes to lunch

What happens: locks and snapshots stay open; vacuum can be blocked.

Fix: keep transactions short; in ops, monitor:

SELECT pid, usename, state, xact_start, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;

Mistake 2: Forgetting to Handle an Error State

What happens: after an error, every subsequent statement fails until rollback.

Fix: use savepoints for recoverable steps, otherwise ROLLBACK and restart the transaction.


Mistake 3: Running Non-Transactional Commands in Transactions

Wrong:

BEGIN;
VACUUM public.orders;
COMMIT;

Typical error:

ERROR:  VACUUM cannot run inside a transaction block

Fix: run these commands outside a transaction (or schedule them as standalone statements).

Debugging tips:

  1. If you see “current transaction is aborted”, roll back (or roll back to a savepoint).
  2. Inspect open transactions with pg_stat_activity.
  3. Add lock_timeout to avoid surprise lock waits.
  4. Verify target rows with SELECT before you write.

Best Practices

  • ✅ Keep transactions short and focused; ❌ hold transactions open while waiting on user input.
  • ✅ Use savepoints for recoverable steps; ❌ force full rollbacks for small failures.
  • ✅ Use RETURNING to avoid re-query races; ❌ re-select rows with weak predicates.
  • ✅ Set local timeouts (lock_timeout, statement_timeout); ❌ allow unbounded lock waits.
  • ✅ Verify important writes inside the transaction before commit; ❌ commit blindly.

Hands-On Practice

Exercise 1 (Easy): Basic rollback

Task: In a transaction, update a row, then roll back and confirm the change did not persist.

-- Your SQL here

Solution:

BEGIN;
UPDATE accounts SET balance = balance + 1 WHERE account_id = 1;
ROLLBACK;
SELECT balance FROM accounts WHERE account_id = 1;

Exercise 2 (Medium): Use a savepoint

Task: Create a savepoint, run a statement that fails, roll back to the savepoint, and continue.

-- Your SQL here

Solution:

BEGIN;
SAVEPOINT s;
INSERT INTO emails (email) VALUES ('a@example.com');
ROLLBACK TO SAVEPOINT s;
INSERT INTO emails (email) VALUES ('c@example.com');
COMMIT;

Exercise 3 (Advanced): Add transaction-local timeouts

Task: Start a transaction and set lock_timeout and statement_timeout locally.

-- Your SQL here

Solution:

BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '10s';
COMMIT;

Connection to Other Concepts

ConceptWhy it matters
Isolation levelscontrol visibility and anomalies under concurrency
Row lockingsafe read-modify-write patterns use locks
Triggerstrigger failures abort the statement/transaction
Schedulingmaintenance jobs often rely on transactional boundaries
Backup and recoverydurability complements recovery procedures

Visual Learning Diagram

flowchart LR
A[BEGIN] --> B[Statement 1]
B --> C[Statement 2]
C --> D{Error?}
D -->|No| E[COMMIT\npersist]
D -->|Yes| F[ROLLBACK\ndiscard]
C --> G[SAVEPOINT\noptional]
G --> H[ROLLBACK TO SAVEPOINT\nrecover]

classDef allNodes fill:#3e3e3e,stroke:#ffffff,stroke-width:2px,color:#f5f5f5
classDef highlight fill:#3e3e3e,stroke:#ffffff,stroke-width:4px,color:#f5f5f5
class A,B,C,D,E,F,G,H allNodes
class E highlight

Common Pitfalls

PitfallConsequencePrevention
Idle transactionsvacuum blocked, lock bloatkeep transactions short, monitor activity
Missing savepointsfull rollback on small errorsuse savepoints strategically
No timeoutsstuck sessions on locksset lock_timeout/statement_timeout
Weak verificationwrong rows updatedverify with SELECT and stable keys
Multi-step writes without transactionpartial statewrap related steps in a transaction

Quick Reference

BEGIN;
COMMIT;
ROLLBACK;
SAVEPOINT s; ROLLBACK TO SAVEPOINT s;
SET LOCAL lock_timeout = '2s';

What's Next