DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Database Transactions: ACID Properties in Plain English

What Is a Transaction?

A transaction is a group of database operations that either all succeed or all fail together.

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Enter fullscreen mode Exit fullscreen mode

If the second update fails, the first one rolls back automatically. Money doesn't disappear into the void.

ACID: The Four Guarantees

Atomicity

"All or nothing."

BEGIN;
  INSERT INTO orders (user_id, total) VALUES (42, 9900);
  INSERT INTO order_items (order_id, product_id, qty) VALUES (LASTVAL(), 7, 2);
  UPDATE inventory SET stock = stock - 2 WHERE product_id = 7;
COMMIT;
-- If any statement fails, ALL are rolled back
Enter fullscreen mode Exit fullscreen mode

No partial orders. No inventory decremented without an order. No order without items.

Consistency

"Data must be valid before and after."

Constraints prevent invalid states:

-- Balance can never go negative
ALTER TABLE accounts ADD CONSTRAINT positive_balance CHECK (balance >= 0);

BEGIN;
  UPDATE accounts SET balance = balance - 1000 WHERE id = 1; -- balance is 500
  -- This VIOLATES the CHECK constraint
  -- Entire transaction rolls back
ROLLBACK; -- automatic
Enter fullscreen mode Exit fullscreen mode

Isolation

"Concurrent transactions don't see each other's partial work."

The tricky one. Four isolation levels:

Read Uncommitted (lowest):

-- Transaction A
BEGIN;
UPDATE products SET price = 999 WHERE id = 1;
-- not yet committed

-- Transaction B (dirty read)
SELECT price FROM products WHERE id = 1;
-- Returns 999 even though A hasn't committed
-- If A rolls back, B saw data that never existed
Enter fullscreen mode Exit fullscreen mode

Read Committed (default in PostgreSQL):

-- Transaction B only sees committed data
-- No dirty reads, but non-repeatable reads are possible
SELECT price FROM products WHERE id = 1; -- 500
-- Another transaction commits a change here
SELECT price FROM products WHERE id = 1; -- 750 (different!)
Enter fullscreen mode Exit fullscreen mode

Repeatable Read:

-- Same query returns same result within a transaction
SELECT price FROM products WHERE id = 1; -- 500
-- Even if another transaction commits a change
SELECT price FROM products WHERE id = 1; -- still 500
Enter fullscreen mode Exit fullscreen mode

Serializable (highest):

-- Transactions behave as if they ran sequentially
-- Most consistent, highest lock contention
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Enter fullscreen mode Exit fullscreen mode

Durability

"Committed data survives crashes."

Once COMMIT returns, the data is written to disk. PostgreSQL's WAL (Write-Ahead Log) guarantees this.

In Application Code

Node.js + Prisma

// Transfer funds atomically
async function transferFunds(fromId: string, toId: string, amount: number) {
  return prisma.$transaction(async (tx) => {
    const sender = await tx.account.findUnique({ where: { id: fromId } });

    if (!sender || sender.balance < amount) {
      throw new Error('Insufficient funds');
    }

    await tx.account.update({
      where: { id: fromId },
      data: { balance: { decrement: amount } },
    });

    await tx.account.update({
      where: { id: toId },
      data: { balance: { increment: amount } },
    });

    await tx.transaction.create({
      data: { fromId, toId, amount, type: 'transfer' },
    });
  });
  // If any of the above throws, ALL changes are rolled back
}
Enter fullscreen mode Exit fullscreen mode

Raw SQL Transaction

import { Pool } from 'pg';

async function createOrderWithItems(userId: string, items: OrderItem[]) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const orderResult = await client.query(
      'INSERT INTO orders (user_id, status) VALUES ($1, $2) RETURNING id',
      [userId, 'pending']
    );
    const orderId = orderResult.rows[0].id;

    for (const item of items) {
      await client.query(
        'INSERT INTO order_items (order_id, product_id, qty, price) VALUES ($1, $2, $3, $4)',
        [orderId, item.productId, item.qty, item.price]
      );

      await client.query(
        'UPDATE inventory SET stock = stock - $1 WHERE product_id = $2',
        [item.qty, item.productId]
      );
    }

    await client.query('COMMIT');
    return orderId;

  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

Long transactions hold locks:

BEGIN;
  SELECT * FROM orders WHERE user_id = 1 FOR UPDATE; -- locks rows
  -- ... do 10 seconds of application logic ...
  UPDATE orders SET status = 'processed' WHERE user_id = 1;
COMMIT;
-- Other transactions trying to access these rows are blocked for 10s
Enter fullscreen mode Exit fullscreen mode

Keep transactions short. Do application logic outside the transaction when possible.

N+1 in transactions:

// Bad: N separate transactions
for (const item of items) {
  await prisma.inventory.update({ where: { id: item.id }, data: { stock: { decrement: 1 } } });
}

// Good: one transaction
await prisma.$transaction(
  items.map(item =>
    prisma.inventory.update({ where: { id: item.id }, data: { stock: { decrement: 1 } } })
  )
);
Enter fullscreen mode Exit fullscreen mode

Transactions aren't magic—they're a contract. Understanding ACID helps you know exactly what you're getting and what you're not.


Production-ready database patterns with Prisma and PostgreSQL: Whoff Agents AI SaaS Starter Kit includes transaction utilities and optimistic locking out of the box.

Top comments (0)