What Actually Happens Inside a Database Transaction

by Eric Hanson, Backend Developer at Clean Systems Consulting

The transaction that seemed simple

You have a funds transfer: debit account A, credit account B. You wrap it in a transaction. It's atomic — either both happen or neither does. This is the textbook use case, and it's clear.

But then you have a transaction that: looks up an inventory item, checks a price, applies a discount, inserts an order, updates inventory, and sends a notification via a stored procedure. It holds locks for 800ms. Concurrency drops. Support tickets start arriving. You're not sure what the transaction is actually doing during those 800ms.

Understanding the mechanics of transactions — not just the ACID guarantees — is what separates developers who can debug concurrency problems from those who can only restart the database and hope.

ACID: what each letter actually means

Atomicity: all operations in a transaction commit together or none do. Implemented via the write-ahead log (WAL) — operations are written to the log first, and the log is what determines whether a transaction committed. If the server crashes mid-transaction, the WAL is replayed on recovery and uncommitted transactions are rolled back.

Consistency: the database moves from one valid state to another. Enforced through constraints (NOT NULL, UNIQUE, CHECK, FOREIGN KEY) evaluated at commit time. If any constraint fails, the transaction rolls back.

Isolation: concurrent transactions see a consistent view of the data, as if they ran serially. Implemented via locks (in MySQL's InnoDB by default) or MVCC (in PostgreSQL and InnoDB's snapshot reads). The degree of isolation is configurable — see isolation levels.

Durability: a committed transaction survives a server crash. Guaranteed by the write-ahead log being flushed to durable storage before the commit acknowledgment is sent to the client. fsync = on in PostgreSQL, innodb_flush_log_at_trx_commit = 1 in MySQL.

MVCC: how reads and writes coexist

PostgreSQL uses Multi-Version Concurrency Control (MVCC). Rather than blocking readers when a writer updates a row, MVCC creates a new version of the row:

  • The old version remains visible to transactions that started before the update
  • The new version is visible to transactions that start after the commit
  • Both versions coexist in the heap until the old version is cleaned up by VACUUM

This means readers never block writers and writers never block readers in PostgreSQL. The tradeoff: table bloat from accumulated row versions, requiring regular VACUUM to reclaim space.

Timeline:
T=0: Transaction A starts (sees snapshot at T=0)
T=1: Transaction B updates row X (creates new version X')
T=2: Transaction A reads row X — still sees old version (its snapshot is at T=0)
T=3: Transaction B commits
T=4: Transaction C starts (sees snapshot at T=4, sees X')
T=5: Transaction A reads row X — still sees old version (snapshot hasn't changed)
T=6: Transaction A commits

This is why a long-running transaction in PostgreSQL can prevent VACUUM from cleaning up old row versions — VACUUM can't remove a row version that might still be visible to an active transaction.

The write-ahead log

Every change — INSERT, UPDATE, DELETE — is first written to the WAL (Write-Ahead Log) before it's applied to the actual data pages. The WAL is sequential and append-only, making it fast to write.

On commit, the WAL is flushed to disk. Only then does the client receive the commit acknowledgment. This is what makes durability work: even if the server crashes immediately after acknowledging the commit, the WAL contains the complete record of what needs to be applied.

WAL is also the mechanism for replication. Standby replicas receive and replay the WAL stream, staying in sync with the primary.

Lock acquisition order

When a transaction acquires locks on multiple rows or tables, the order matters. If transaction A locks row 1 then row 2, and transaction B locks row 2 then row 1, and they run concurrently, they can deadlock — each waiting for the other's lock.

-- Transaction A:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- locks row 1
-- ... some delay ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- waits for row 2

-- Transaction B (concurrent):
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- locks row 2
UPDATE accounts SET balance = balance + 50 WHERE id = 1;   -- waits for row 1
-- DEADLOCK

The fix: always acquire locks in a consistent order. In the funds transfer example, always lock the lower account ID first:

-- Application enforces consistent lock order
UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(:from_id, :to_id);
UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(:from_id, :to_id);

What happens during those 800ms

When a transaction holds locks, other transactions trying to modify the same rows wait. In PostgreSQL, the default lock wait timeout is indefinite — transactions queue. In high-concurrency scenarios, lock queuing cascades: one slow transaction blocks many others, and their lock waits accumulate.

You can see lock waits in PostgreSQL:

SELECT
  pid,
  wait_event_type,
  wait_event,
  state,
  query_start,
  query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

Any row here is a transaction currently blocked waiting for a lock. If you see many rows, you have a lock contention problem. Finding the blocking transaction:

SELECT pg_blocking_pids(pid) AS blocked_by, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

The practical insight

A transaction is not just a correctness boundary — it's a lock-holding interval. The longer your transaction runs, the longer it holds locks, the more concurrency you sacrifice. This is why "keep transactions short" is not generic advice — it's a direct consequence of how locking and MVCC work. Design your transactions to do the minimum work necessary to maintain consistency, and nothing more.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Stop Writing "Fixed Bug" as Your Commit Message

A commit message that says "fixed bug" is worse than no message at all — it creates false confidence that the history is documented while giving future developers nothing to work with.

Read more

What Actually Happens When You Put a Load Balancer in Front of Your App

Load balancers are simple in concept and full of operational surprises in practice. Understanding what they actually do — and what they assume about your application — prevents a category of production incidents that look mysterious until they aren't.

Read more

The True Cost of Maintaining Software

Launching an app feels like crossing the finish line. In reality, it’s just the moment the meter really starts running.

Read more

The Essential Tools We Use to Work Remotely

Remote work sounds simple—just a laptop and internet. In reality, the right tools are what make everything actually work.

Read more