⚡ AMP
Database

Database transactions and ACID properties explained

A practical guide to database transactions and ACID properties explained.

Nitheesh DR 4 min read

Database Transactions and ACID Properties Explained

A payment goes through, but the order status update that's supposed to happen right after it crashes the app server. The customer got charged, but their order shows as "pending" forever, and support has no idea why. This is exactly the class of bug transactions exist to prevent — and understanding ACID isn't academic trivia, it's the difference between "the crash caused a support ticket" and "the crash caused a customer to lose money."

What a transaction actually is

A transaction groups multiple operations so they either all succeed or all fail together — there's no partial state where some happened and others didn't.

BEGIN;

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

COMMIT;

If the process crashes between the two UPDATE statements, COMMIT never runs, and the database rolls back — account 1 never lost the $100. Without a transaction, a crash there leaves one account debited and the other never credited: money that simply vanished from the system.

ACID, with concrete meaning attached to each letter

Isolation levels: where most real bugs live

Isolation is the property developers actually get wrong in practice, because "isolated" doesn't mean one universal thing — it's a spectrum with real trade-offs:

-- PostgreSQL default: READ COMMITTED
-- Sees other transactions' committed changes, but each statement
-- within your transaction can see different data if something
-- else commits in between.

BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- Your transaction sees a consistent snapshot for its entire duration,
-- even if other transactions commit changes while yours is running.

A classic bug this catches: you read a row's value, do some calculation, then write an update based on that calculation — but another transaction changed the row in between your read and write. REPEATABLE READ (or explicit row locking with SELECT ... FOR UPDATE) prevents this "lost update" problem; the default READ COMMITTED does not.

Row locking for read-then-write logic

BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- other transactions trying to touch this row now wait
UPDATE products SET stock = stock - 1 WHERE id = 42;
COMMIT;

FOR UPDATE locks the selected row until your transaction commits or rolls back, so two concurrent "buy the last item" requests can't both succeed based on stale stock counts.

Common mistakes

What I'd actually use

READ COMMITTED for the vast majority of application code — it's the sensible default for a reason. Reach for SELECT ... FOR UPDATE or REPEATABLE READ specifically for read-then-write sequences where a race condition would cause real damage (inventory counts, balance transfers, anything involving money or finite resources). Keep transactions as short as possible — begin them right before the work that needs atomicity, commit immediately after.

Next steps

Find one place in your codebase that reads a value, computes something from it, then writes a new value back — a stock decrement, a balance update, a counter increment. Check whether it's wrapped in a transaction with appropriate locking. If not, that's your highest-value place to add one.