Transactions from Application Code
BEGIN, COMMIT and ROLLBACK as things your code controls: bound to one connection, ended by an error you did not expect, and retried when the database says so.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What does a transaction actually give my handler, and what do I have to do to get it?
Placing an order must insert an order row, insert its line items and decrement stock. Either all three happen or none of them do — a paid order with no items is not an acceptable state.
Do the three writes in sequence. They are all in the same handler, they will all succeed, and if one fails we can clean up afterwards.
The second insert fails a constraint. The order row is already committed, so the database now holds an order with no line items and nothing will ever notice.
- The second insert fails a constraint. The order row is already committed, so the database now holds an order with no line items and nothing will ever notice.
- The process is killed between writes two and three — a deploy, an OOM kill, a scale-in. There is no code path that runs to clean up, because the process is gone (Graceful Shutdown).
- The "clean up afterwards" path is itself a sequence of writes that can fail halfway, which is the same problem one level deeper.
- Meanwhile another request reads the half-written order and acts on it, because without a transaction there is no point at which the three writes become visible together.
What is actually happening
- A transaction is a bracket around statements:
BEGIN, work, thenCOMMIT(all of it becomes durable and visible at once) orROLLBACK(none of it happened). - The single most important application-side fact: a transaction belongs to a connection. Every statement in it must run on that same connection. Code that acquires a second connection from the pool inside a transaction is running two transactions that know nothing about each other (Connection Pools).
- Without an explicit transaction, drivers run in autocommit: every statement is its own transaction. That is why the naive version has three atomic writes rather than one.
- Isolation decides what a transaction sees of concurrent work. Postgres and MySQL both implement it with multi-version concurrency control, so readers do not block writers (MVCC: Multi-Version Concurrency Control).
- Atomicity and durability come from the write-ahead log: changes are logged before they are applied, and
COMMITis the moment the log record is durable (Write-Ahead Logging). - "Nested transactions" in most libraries are savepoints — a rollback point inside the outer transaction. Rolling back to a savepoint undoes part of the work; it does not commit anything.
- A commit can fail. A serialization failure, a deferred constraint or a lost connection all mean the work did not happen, and the caller has to decide what to do about it.
The bracket, and what it is attached to
Two facts do most of the work in this lesson. The first is that a transaction is a bracket: nothing inside it is visible to anyone else until COMMIT, and ROLLBACK makes all of it never have happened. The second, and the one that causes real bugs, is that the bracket lives on a connection.
That second fact explains a whole class of confusing behaviour. A helper function that grabs its own connection from the pool is outside your transaction, even though it is inside your function. Its writes commit independently. Your rollback leaves them behind. The code looks nested; the transactions are siblings.
- 1Acquire connection
Takes one connection from the pool for the whole bracket.
fails by Pool exhausted — the handler waits here before doing any work (Connection Pools).
- 2BEGIN
Starts the transaction; on MVCC engines, establishes the snapshot.
fails by Forgotten entirely, so each statement autocommits and atomicity is imaginary.
- 3Statements
Reads and writes on that connection; writes take row locks and hold them.
fails by A statement executed on a different connection — outside the bracket, invisible to the rollback.
- 4Constraint checks
Unique, foreign key and check constraints enforced at write or at commit if deferred.
fails by On Postgres, one violation poisons the transaction; every later statement fails until rollback.
- 5COMMIT
Makes the WAL record durable; everything becomes visible at once.
fails by Serialization failure, deferred constraint violation, or a dropped connection — the work did not happen.
- 6Release connection
Returns it to the pool.
fails by Skipped on an error path: a leaked connection still inside an open transaction, holding locks (Connection Pool Exhaustion).
- 7Post-commit effects
Publish, enqueue, send, invalidate cache.
fails by Anything here can fail after the commit is irreversible (The Dual Write Problem).
Scoping it so it cannot leak
Almost every transaction bug in application code is a scoping bug: an early return, a thrown error or a second connection. The defence is structural — one helper that owns the bracket, passes the handle down, and cannot be exited without either committing or rolling back.
Note the retry loop. Serialization failures and deadlocks are not bugs; they are the database telling you to run the whole thing again. That instruction can only be followed if the unit of work is a function you can call twice.
1async function withTransaction<T>(fn: (tx: Client) => Promise<T>): Promise<T> {2 for (let attempt = 0; ; attempt++) {3 const tx = await pool.connect()4 try {5 await tx.query('BEGIN')6 const out = await fn(tx) // every statement uses `tx`7 await tx.query('COMMIT')8 return out9 } catch (err) {10 await tx.query('ROLLBACK').catch(() => {})11 if (isRetryable(err) && attempt < 3) continue // 40001, 40P0112 throw err13 } finally {14 tx.release() // runs on every path, including throw15 }16 }17}18 19await withTransaction(async (tx) => {20 const { rows } = await tx.query(21 'INSERT INTO orders (tenant_id, total) VALUES ($1, $2) RETURNING id',22 [tenantId, total],23 )24 await insertLineItems(tx, rows[0].id, items) // handle passed down25 await decrementStock(tx, items)26})Three properties to copy: the handle is a parameter so no callee can escape the bracket, finally releases on every path, and the retry re-runs the *whole* unit rather than the failed statement. isRetryable here means the SQLSTATEs the database uses for serialization failure and deadlock — not "the operation is safe to retry", which is a different property entirely (Retries).
Isolation levels are not portable vocabulary
Isolation level names are standardised and their behaviour is not. The most consequential example: REPEATABLE READ does not mean the same thing on Postgres and MySQL, and code that is correct on one can lose updates on the other.
On Postgres, REPEATABLE READ is snapshot isolation. The transaction sees the database as of its first statement, and if it tries to update a row another transaction has since changed, it is aborted with a serialization failure that you must retry. On MySQL/InnoDB, plain reads use a consistent snapshot in the same way — but *locking* reads and writes (SELECT ... FOR UPDATE, UPDATE, DELETE) read the latest committed version instead, and block rather than abort. A read-modify-write can therefore see one value with a plain SELECT and a different one with SELECT ... FOR UPDATE in the same transaction.
The practical rule: do not carry an isolation-level intuition across engines, and do not rely on an isolation level to fix a read-modify-write race. Use an atomic statement, a version column or an explicit lock, all of which behave the same way everywhere (Optimistic Concurrency, Pessimistic Locking).
| Level | Postgres | MySQL / InnoDB |
|---|---|---|
| Default | READ COMMITTED | REPEATABLE READ |
| READ COMMITTED | Each statement gets a fresh snapshot | Same idea; each consistent read is fresh |
| REPEATABLE READ | Snapshot isolation; write conflict aborts with 40001 | Snapshot for plain reads; locking reads see latest committed and block |
| Phantom reads at REPEATABLE READ | Prevented by the snapshot | Prevented for locking reads by gap / next-key locks |
| SERIALIZABLE | Serializable snapshot isolation: no extra blocking, more aborts to retry | Plain reads become locking reads: more blocking, fewer aborts |
| What your code must do | Be able to retry the whole transaction | Be able to wait, and to handle lock-wait timeout |
How to build it
Most important first.
- Wrap the unit of work in one explicit transaction helper that guarantees rollback on any throw — a
finallythat releases, a context manager, awithTransaction(fn)wrapper. Never hand-rollBEGIN/COMMITin a handler. - Pass the transaction handle explicitly into every function that participates, so it is visible in the signature which calls are inside the bracket and which are not.
- Keep the bracket short. Everything between
BEGINandCOMMITholds a connection and possibly locks (External Calls Inside a Transaction). - Let the database enforce invariants with constraints — unique, foreign key, check — so a race that slips past application logic still cannot commit an invalid state (Database Constraints).
- Decide explicitly whether the work is read-only; a read-only transaction is cheaper and documents intent.
- Handle serialization failures and deadlocks by retrying the whole transaction, with a bounded attempt count and jitter. Retrying one statement inside a dead transaction does not work (Backoff and Jitter).
- Know where your framework already opened one. Request-scoped sessions mean you are inside a transaction from the first line of the handler (What an ORM Actually Does).
What can go wrong
- An exception thrown on a path that skips the rollback, leaving the transaction open until the connection is reclaimed — a held connection and held locks for as long as that takes.
- A second connection acquired inside a transaction: the inner writes commit independently, and the outer rollback does not undo them.
- A commit that fails after the handler has already told a queue, a cache or the client that the work succeeded.
- Long transactions accumulating locks and, on Postgres, holding back the vacuum horizon so dead row versions cannot be cleaned up — table bloat with a slow-query symptom.
- Retrying a transaction whose effects are not confined to the database, so the retry repeats an email or a charge (Idempotency in Backends).
- Assuming a
ROLLBACKundoes everything: in-memory state, a sent message and a written file are all still there.
- Read-modify-write inside a transaction is still a race at
READ COMMITTED: both transactions read the old value, both write, the later write wins (Backend Races). - Check-then-insert races even with a transaction — both see no existing row, both insert. A unique constraint turns that into an error one of them can handle; nothing else does (Database Constraints).
- Two transactions taking the same locks in different orders deadlock, and the database resolves it by killing one (Deadlocks in Application Code).
- A transaction is not an authorization boundary. Every statement inside it still needs its own tenant predicate and object check (Object-Level Authorization).
- Audit records written in the same transaction as the change are atomic with it, which is usually what you want — an audit trail that can disagree with the data is worse than none (Audit Logs for Privileged Actions).
- Rollback does not un-log. If you logged the attempted values, they are still in your logs after the transaction disappeared, personal data included (Secrets in Logs).
- An open transaction is a resource an attacker can hold. An endpoint that begins a transaction and then waits on caller-controlled input is a denial-of-service primitive (Resource Limits).
- "The transaction makes it safe." It makes it atomic. Two concurrent transactions can still both read the same value and both write, and at the default isolation level that is a lost update (Optimistic Concurrency).
- "Rollback undoes what happened." It undoes the database changes on that connection. Emails, HTTP calls, queue messages and in-memory changes are unaffected.
- "We use transactions, so we do not need constraints." Application checks run before the write; constraints run at the write. Only the second one is a guarantee.
- "REPEATABLE READ means the same thing everywhere." It does not, and the differences are behavioural, not academic — see the scopes on this lesson.
- "A commit always succeeds." It is the most likely statement to fail, because it is where conflicts, constraints and durability are resolved.
Operating it
- Transaction duration as a metric, not just query duration. The distribution's tail is where held locks and pool pressure live.
- Count of open transactions and, specifically, sessions idle inside a transaction — the single clearest signal of a leaked bracket.
- Rollback rate by cause: constraint violation, serialization failure, deadlock, application error. They call for entirely different responses.
- Retry counts on serializable workloads. A rising retry rate means contention is increasing before it means anything is failing (Low CPU, High Latency: Lock Contention).
- At 10x concurrency, transaction *duration* becomes the dominant variable: every millisecond inside the bracket is a millisecond of held connection and held locks, multiplied by concurrency.
- Contention is superlinear. Twice the concurrency on the same hot rows is more than twice the waiting, because waiters queue behind each other (Queueing: Why Systems Get Slow Before They Get Broken).
- At 100x, hot-row transactions are re-designed rather than tuned: append instead of update, aggregate later, shard the counter.
- Read-only transactions can move to replicas, at the cost of reading slightly stale data (Read Replicas From the Application).
- Atomicity costs concurrency. Locks held for the bracket are unavailable to everyone else, so correctness here is paid for in throughput there.
- Stronger isolation removes anomalies and adds either blocking or aborts you must retry. There is no setting that gives you both.
- Database constraints are the most reliable place to enforce invariants and the least flexible: the error arrives as a driver exception that must be translated into something a caller can understand (Reporting Validation Failures).
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe bracket, connection affinity, savepoints and retry-the-whole-transaction hold for any relational database and any driver.
- DATABASE-SPECIFICDefault isolation differs: Postgres defaults to READ COMMITTED, MySQL/InnoDB to REPEATABLE READ. Identical application code therefore sees different concurrent behaviour on the two engines with no configuration anywhere in your project.
- DATABASE-SPECIFICError handling differs sharply. In Postgres any statement error aborts the whole transaction — every subsequent statement fails with "current transaction is aborted" until you roll back or roll back to a savepoint. MySQL does not: a failed statement leaves the transaction usable, so code that "carries on after the error" works there and silently commits partial work there only.
- DATABASE-SPECIFICSQLite serialises writers at the database level: one writer at a time, and a competing writer gets SQLITE_BUSY rather than waiting on a row lock. Concurrency advice tuned for Postgres or MySQL row locking does not transfer.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.