Database

Database Transactions: ACID Properties

Mayur Dabhi
Mayur Dabhi
April 15, 2026
14 min read

Say a checkout endpoint creates an order, decrements stock, and charges a wallet balance in three separate queries. The PHP worker gets killed after the second query. Now you have an order and reduced stock, but nobody paid, and nothing in your logs says why. Bugs like this usually show up weeks later as numbers that don't add up.

That's the problem database transactions solve, and ACID is the set of guarantees they give you. This post goes through each of the four properties with SQL and framework examples, then covers isolation levels, durability settings, savepoints, distributed transactions, and the mistakes I see most often.

Why ACID Matters

A missing transaction rarely fails loudly. It leaves half-written data behind, and you only notice when a report or a customer points out that something doesn't match. By then it's hard to work out which requests caused it.

What is a Database Transaction?

You group several reads and writes together and tell the database to treat them as one unit. That group is a transaction. Either every change gets committed, or the whole thing rolls back and the database looks as if you never started.

The classic example is a bank transfer:

  1. Read the balance of Account A
  2. Deduct $500 from Account A
  3. Read the balance of Account B
  4. Add $500 to Account B

All four steps must succeed together. If step 2 succeeds but step 4 fails, money disappears. Transactions guarantee this "all or nothing" behavior.

SQL: Basic Transaction Syntax
-- Start a transaction
BEGIN;

-- Deduct from Account A
UPDATE accounts SET balance = balance - 500 WHERE id = 1;

-- Add to Account B
UPDATE accounts SET balance = balance + 500 WHERE id = 2;

-- If everything succeeded, commit
COMMIT;

-- If something went wrong, roll back all changes
-- ROLLBACK;
BEGIN Operations UPDATE accounts INSERT orders DELETE temp... OK? COMMIT ROLLBACK Yes No

Transaction lifecycle: BEGIN → Operations → COMMIT or ROLLBACK

ACID: The Four Pillars

ACID stands for Atomicity, Consistency, Isolation, and Durability. Together they describe what "reliable" means for a transactional database. The acronym was coined by Theo Härder and Andreas Reuter in 1983, building on Jim Gray's earlier work on transaction processing.

Property Guarantee Failure Scenario Prevented
Atomicity All or nothing Partial updates leaving corrupt state
Consistency Valid state before and after Constraint violations, invalid data
Isolation Concurrent transactions don't interfere Race conditions, dirty reads
Durability Committed data survives failures Data loss after crash or power failure

Atomicity: All or Nothing

With atomicity, there's no in-between state. Every operation in the transaction gets applied, or none of them do.

Under the hood, the engine keeps a write-ahead log (WAL) or an undo log. It records what it's about to change before changing it, so it can reverse everything if something fails partway.

MySQL: Atomicity in Action
START TRANSACTION;

-- Step 1: Deduct from sender
UPDATE accounts
SET balance = balance - 500
WHERE user_id = 1 AND balance >= 500;

-- Check if the update actually affected a row
-- (balance was sufficient)
-- In application code, check affected rows here

-- Step 2: Credit to receiver
UPDATE accounts
SET balance = balance + 500
WHERE user_id = 2;

-- Step 3: Log the transfer
INSERT INTO transfer_log (from_user, to_user, amount, created_at)
VALUES (1, 2, 500, NOW());

-- All succeeded, so commit
COMMIT;

-- If any step failed, the application would call:
-- ROLLBACK;
-- And the database reverts to the state before BEGIN
Common Atomicity Mistake

Not all databases enable transactions by default. MySQL's MyISAM storage engine does NOT support transactions; only InnoDB does. Modern MySQL defaults to InnoDB, but older databases often have a few MyISAM tables left over. The nasty part: ROLLBACK on a MyISAM table doesn't error. MySQL just warns that some non-transactional tables couldn't be rolled back, and the changes stay. Run SHOW TABLE STATUS to check the engine.

Atomicity in Application Code

Most frameworks provide transaction helpers so you don't have to manually write BEGIN/COMMIT/ROLLBACK:

// Laravel automatically handles ROLLBACK on exception
DB::transaction(function () use ($fromId, $toId, $amount) {
    // Deduct from sender
    Account::where('user_id', $fromId)
        ->decrement('balance', $amount);

    // Credit receiver
    Account::where('user_id', $toId)
        ->increment('balance', $amount);

    // Log the transfer
    TransferLog::create([
        'from_user' => $fromId,
        'to_user'   => $toId,
        'amount'    => $amount,
    ]);
});
// If any exception is thrown inside, Laravel auto-rolls back
const t = await sequelize.transaction();

try {
    await Account.decrement('balance', {
        by: amount,
        where: { userId: fromId },
        transaction: t
    });

    await Account.increment('balance', {
        by: amount,
        where: { userId: toId },
        transaction: t
    });

    await TransferLog.create(
        { fromUser: fromId, toUser: toId, amount },
        { transaction: t }
    );

    await t.commit();
} catch (error) {
    await t.rollback();
    throw error;
}
from sqlalchemy.orm import Session

def transfer_funds(db: Session, from_id: int, to_id: int, amount: float):
    try:
        # SQLAlchemy sessions are transactional by default
        sender = db.query(Account).filter_by(user_id=from_id).one()
        receiver = db.query(Account).filter_by(user_id=to_id).one()

        if sender.balance < amount:
            raise ValueError("Insufficient funds")

        sender.balance -= amount
        receiver.balance += amount

        db.add(TransferLog(
            from_user=from_id, to_user=to_id, amount=amount
        ))

        db.commit()
    except Exception:
        db.rollback()
        raise

Consistency: Valid State Before and After

A transaction should move the database from one valid state to another. Whatever it writes has to satisfy your constraints, cascades, triggers, and application rules. If it would break one, it gets rejected.

In practice consistency lives in two places:

SQL: Consistency via Constraints
CREATE TABLE accounts (
    id          INT PRIMARY KEY AUTO_INCREMENT,
    user_id     INT NOT NULL,
    balance     DECIMAL(15, 2) NOT NULL DEFAULT 0.00,

    -- CHECK constraint: balance can never go below zero
    CONSTRAINT chk_balance CHECK (balance >= 0),

    -- Foreign key: user must exist
    CONSTRAINT fk_user FOREIGN KEY (user_id)
        REFERENCES users(id) ON DELETE CASCADE
);

-- This will FAIL: consistency violation
UPDATE accounts SET balance = -100 WHERE id = 1;
-- ERROR 3819: Check constraint 'chk_balance' is violated.

-- This will ALSO FAIL: foreign key violation
INSERT INTO accounts (user_id, balance) VALUES (9999, 100.00);
-- ERROR 1452: Cannot add or update a child row: a foreign key constraint fails

Consistency is the one ACID property that's partly your job. The database can only enforce rules you've actually told it about. If your rule is "an order total must equal the sum of its line items," you have to check that in code (or write a trigger for it).

Isolation: Concurrent Transactions Don't Interfere

Two requests hit the same row at the same moment. Each one is correct on its own, yet together they produce the wrong result. Isolation controls when one transaction's changes become visible to others, and it's the property with the most knobs and the most surprises.

Isolation Anomalies

These are the anomalies you get with weak or no isolation:

Anomaly Description Example
Dirty Read Reading uncommitted changes from another transaction Tx B reads a balance updated by Tx A before Tx A commits or rolls back
Non-repeatable Read Same query returns different results within one transaction Tx A reads a row twice; Tx B updates it between the reads
Phantom Read A query returns different sets of rows when re-executed Tx A counts rows twice; Tx B inserts a new row between the counts
Lost Update One transaction's update overwrites another's Tx A and Tx B both read balance=100, both add 50, so the result is 150, not 200

Isolation Levels

The SQL standard defines four isolation levels, each preventing a different set of anomalies at the cost of more or less concurrency:

← More Concurrency / Less Safety More Safety / Less Concurrency → READ UNCOMMITTED Dirty Read: ✓ Non-repeat: ✓ Phantom: ✓ Fastest READ COMMITTED Dirty Read: ✗ Non-repeat: ✓ Phantom: ✓ PG default REPEATABLE READ Dirty Read: ✗ Non-repeat: ✗ Phantom: ✓* MySQL default SERIALIZABLE Dirty Read: ✗ Non-repeat: ✗ Phantom: ✗ Slowest

SQL Isolation Levels and the anomalies they prevent (* MySQL's InnoDB prevents phantoms at REPEATABLE READ using MVCC gap locks)

SQL: Setting Isolation Levels
-- MySQL: Set isolation level for next transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- ... your queries ...
COMMIT;

-- PostgreSQL: Set for the current transaction
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ... your queries ...
COMMIT;

-- Check current isolation level (MySQL)
SELECT @@transaction_isolation;

-- Check current isolation level (PostgreSQL)
SHOW transaction_isolation;

How Isolation Is Implemented: MVCC

Most modern databases (PostgreSQL, MySQL/InnoDB, SQLite) implement isolation using Multi-Version Concurrency Control (MVCC). Instead of locking rows to block readers, MVCC keeps multiple versions of each row. Each transaction sees a consistent snapshot of the database from the moment it started, allowing reads and writes to proceed concurrently without blocking each other.

MVCC Performance Insight

With MVCC, readers don't block writers and writers don't block readers, so PostgreSQL handles a lot of reads even while writes are busy. The cost is vacuum/autovacuum, which has to clean up old row versions. This is also why long-running transactions hurt: as long as one is open, the old versions it might still see can't be removed, and tables bloat. If you see sessions sitting in idle in transaction in pg_stat_activity, that's usually an app that opened a transaction and never finished it.

Durability: Committed Data Survives

Once the database tells your app "committed," that change has to survive a crash, a power cut, or a restart. That's durability, and it means the data must reach non-volatile storage before the commit is acknowledged.

Databases do this with a Write-Ahead Log (WAL). Every change goes to a log file on disk first, and only later to the data files. On recovery after a crash, the database replays the WAL to restore committed transactions that may not have been flushed to the main data files.

1

Transaction Writes to WAL

Before modifying any data page, the database writes a log record describing the change (before and after images) to the Write-Ahead Log on disk.

2

WAL is Fsynced to Disk

The database calls fsync() to force the OS to flush WAL buffers to physical storage. Only after this completes is durability guaranteed.

3

COMMIT Acknowledged

The database responds "COMMIT successful" to the client. At this point, the transaction is durable. Even if the server crashes a millisecond later, the data is safe.

4

Data Pages Written Asynchronously

The actual data file pages (heap, B-tree indexes) are updated in the background. If a crash occurs before this, the WAL is replayed during recovery to reconstruct the changes.

PostgreSQL: Durability Configuration
-- postgresql.conf key settings for durability

-- MUST be on for full durability (default: on)
-- Turning off risks data loss on crash for speed
fsync = on

-- Controls WAL sync method (varies by OS)
-- fdatasync, fsync, open_sync, open_datasync
wal_sync_method = fdatasync

-- Synchronous commit: wait for WAL flush before acknowledging
-- off = async commit (faster, slight data loss risk on crash)
-- on = synchronous commit (default, fully durable)
synchronous_commit = on

-- MySQL InnoDB equivalent:
-- innodb_flush_log_at_trx_commit = 1 (fully durable, default)
-- innodb_flush_log_at_trx_commit = 0 (flush every second, risky)
-- innodb_flush_log_at_trx_commit = 2 (flush on commit, OS may buffer)
Durability Trade-off Warning

Some teams disable fsync or set synchronous_commit = off for performance in development or analytics workloads. Never do this on a production transactional database. With fsync = off, one power failure can corrupt the whole cluster. With synchronous_commit = off you won't get corruption, but you can lose the last few transactions the client already saw as committed. For a money or orders table, that's not a trade I'd make.

Savepoints: Partial Rollbacks

Sometimes you want to undo only part of a transaction. Savepoints are named checkpoints inside a transaction: you can roll back to one without throwing away everything before it. They're handy when one step might fail and you'd rather retry it or try something else. Laravel uses them under the hood when you nest DB::transaction() calls.

SQL: Savepoints
BEGIN;

-- Step 1: Create order header
INSERT INTO orders (user_id, total) VALUES (42, 150.00);
-- Save current point
SAVEPOINT after_order;

-- Step 2: Add line items
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (LAST_INSERT_ID(), 7, 2, 50.00);

INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (LAST_INSERT_ID(), 12, 1, 50.00);

-- Oops, one item is out of stock, but we can keep the order
ROLLBACK TO SAVEPOINT after_order;
-- order row still exists, but line items were undone

-- Insert different items
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (LAST_INSERT_ID(), 5, 3, 50.00);

-- Release savepoint (optional cleanup)
RELEASE SAVEPOINT after_order;

COMMIT;

Distributed Transactions and the CAP Theorem

On a single database server, all of this is well understood. Once your data is split across several servers or microservices, it gets a lot harder, because no single engine owns the whole transaction anymore.

Two-Phase Commit (2PC)

The traditional approach to distributed transactions is the Two-Phase Commit (2PC) protocol. A coordinator asks all participant nodes to "prepare" (phase 1), and if all agree, instructs them to "commit" (phase 2). While correct, 2PC is slow, blocking, and problematic if the coordinator crashes.

Coordinator Node A (DB1) Node B (DB2) Node C (DB3) Phase 1: PREPARE? Phase 2: COMMIT

Two-Phase Commit (2PC): Coordinator orchestrates prepare and commit across all nodes

The CAP Theorem

The CAP Theorem states that a distributed system can guarantee at most two of three properties simultaneously:

Since network partitions are unavoidable in distributed systems, real-world systems must choose between CP (consistent but may be unavailable during a partition, e.g. HBase, ZooKeeper) or AP (available but may return stale data, e.g. Cassandra, DynamoDB). Traditional RDBMS on a single node is CA but not partition-tolerant by nature.

Transactions and Performance

Transactions aren't free. Locking, logging, and coordination all cost something, and most of the problems show up as lock waits under load.

Transaction Performance Best Practices

  • Keep transactions short: Long transactions hold locks longer, blocking other queries. Do all computation outside the transaction, then execute the DB writes quickly.
  • Avoid user interaction inside transactions: Don't wait on anything slow (user input, an HTTP call to a payment API) while a transaction is open. It can hold locks for seconds or minutes.
  • Choose the right isolation level: Use READ COMMITTED for most OLTP workloads unless you need stronger guarantees. SERIALIZABLE is rarely necessary and significantly reduces throughput.
  • Index your WHERE clauses: Unindexed updates inside transactions acquire more locks (table scans lock more rows), increasing contention.
  • Batch wisely: For bulk inserts or updates, use chunked transactions (e.g., 1000 rows per transaction) rather than one giant transaction or one transaction per row.
  • Monitor lock wait timeouts: Configure innodb_lock_wait_timeout (MySQL) or lock_timeout (PostgreSQL) to prevent indefinite blocking in production.
SQL: Chunked Batch Processing
-- Instead of one massive transaction for 1 million rows:
-- BEGIN; UPDATE ... (1M rows); COMMIT;  ← BAD

-- Use chunked transactions for bulk operations
DO $$
DECLARE
    batch_size INT := 1000;
    offset_val INT := 0;
    affected   INT;
BEGIN
    LOOP
        BEGIN
            UPDATE users
            SET    tier = 'gold'
            WHERE  total_spend > 1000
              AND  tier != 'gold'
              AND  id > offset_val
            LIMIT batch_size;

            GET DIAGNOSTICS affected = ROW_COUNT;
            EXIT WHEN affected = 0;

            offset_val := offset_val + batch_size;
            COMMIT;
        END;
    END LOOP;
END $$;

Mistakes I See Most Often

Things to check before going to production

  • Assuming a transaction prevents lost updates: It doesn't, on its own. Two requests that both SELECT a balance, compute a new value in PHP, and UPDATE it will still overwrite each other at READ COMMITTED. Use an atomic UPDATE ... SET balance = balance - 500, or lock the row with SELECT ... FOR UPDATE (lockForUpdate() in Laravel).
  • No retry for deadlocks and serialization failures: Under real concurrency you will eventually see ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction in MySQL, or could not serialize access due to concurrent update in PostgreSQL at REPEATABLE READ and above. Both mean "run the whole transaction again." Laravel's DB::transaction($callback, 3) takes an attempts argument for deadlocks; for PostgreSQL serialization errors you usually need your own retry loop.
  • Side effects inside the transaction: Sending an email or dispatching a queue job inside the transaction means it fires even if the transaction later rolls back, or the worker picks up the job before the row is committed. Dispatch after commit (Laravel has afterCommit() for this).
  • Using SERIALIZABLE everywhere "to be safe": You get more aborted transactions and lower throughput. Most web apps are fine with the default level plus explicit row locks where it matters.

When wouldn't I bother with an explicit transaction? A single INSERT or UPDATE is already atomic on its own, so wrapping it adds nothing. Read-only pages rarely need one either, unless you need several queries to see the same snapshot (a report that totals several tables, say). And across services, I'd reach for an outbox table or a saga with compensating actions before I'd try 2PC. Inside one database, though, any time two writes have to agree with each other, they belong in the same transaction.

Transactions ACID Database MySQL PostgreSQL SQL Data Integrity
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.