Transactions group two or more database operations into a single unit: either every operation inside it takes effect, or none of them do. The textbook example is a bank transfer — subtract from one account, add to another — where leaving the database after only the first half completes would quietly destroy money. Most developers reach for BEGIN/COMMIT when something feels risky without ever pinning down what a transaction actually is. This is that explanation, without wading through isolation levels.
What changed in 2026
- ORMs made transaction boundaries more explicit. Prisma's
$transaction, Drizzle's db.transaction(), and Django's atomic() all make a transaction's start and end visible in code, rather than hidden behind a framework default.
- Database branching changed how teams test transaction logic. Neon-style branched databases let you rehearse a risky multi-step transaction against a disposable copy of real data before it touches anything shared.
- Idle transaction timeouts became a default, not an afterthought. Postgres's
idle_in_transaction_session_timeout and its equivalents are now commonly set out of the box, since a transaction left open by a stalled app was a recurring cause of production incidents.
The transaction as a unit of work
The formal term is "unit of work": a group of operations treated as one indivisible thing from the outside. It is a promise to every other connection: you will never see this transaction half-applied, only the world before it ran or fully after. That promise is what makes multi-step logic safe — transfer funds, place an order and decrement inventory, create a user and its default settings row — without hand-rolling cleanup for every way the second step could fail.
The lifecycle of a transaction
| State |
What it means |
| Active |
Statements are running inside the transaction; nothing is final yet |
| Partially committed |
The last statement finished, but COMMIT has not yet been confirmed durable |
| Committed |
All changes are permanent and visible to other transactions |
| Failed |
An error occurred; the transaction can no longer commit |
| Aborted (rolled back) |
Every change inside the transaction has been undone |
Only "committed" and "aborted" are end states. A transaction that hits an error moves to "failed" and must be explicitly rolled back — in most databases, once one statement errors, every later statement in that transaction also errors until you ROLLBACK and start over.
Autocommit vs explicit transactions
Left to its defaults, most databases run every single statement in its own implicit transaction — this is autocommit. Run one UPDATE, and it commits (or fails) on its own the moment it finishes. That is fine until you need two or more statements to rise or fall together, at which point you open an explicit transaction:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
If the process crashes after the first UPDATE and before COMMIT, the database rolls the whole thing back on its own when the connection drops. Nothing partial survives.
Transactions in application code
In practice, few developers type BEGIN and COMMIT directly; an ORM or query builder wraps the boundary for you:
await db.$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 the callback throws anywhere inside it, the ORM issues a rollback automatically. This is also where scope decisions live — everything inside that callback is one unit; everything outside it is not.
Getting transaction scope right
Scope too narrow, and you lose the guarantee where you needed it — two separate transactions can still leave the database inconsistent if the process dies between them. Scope too broad, and you hold locks for the whole callback, including any slow work left inside it by accident. The rule that holds up: put only database operations inside a transaction, keep it as short as the logic allows, and do anything slow or external — an API call, a file upload, an email — before opening it or after committing. See how a database deadlock forms when two transactions want the same rows, and the deeper ACID transactions reference for the formal guarantees once isolation enters the picture.
FAQ
Is a transaction the same as a lock?
No. A transaction is a unit of work with a boundary; a lock is a mechanism used inside that boundary to keep concurrent transactions off the same rows. A transaction can run with light locking or heavy locking, depending on isolation level and what it touches.
What happens if I never call COMMIT?
The transaction stays open, holding whatever locks it has acquired, until you commit, roll back, or the connection closes. An abandoned open transaction is a common, avoidable cause of production slowdowns.
Do read-only queries need a transaction?
Usually not. A single SELECT is safe outside a transaction unless you specifically need it to see a consistent snapshot alongside other reads.
Where to go next