---
title: Transactions
description: ACID guarantees, isolation levels, savepoints and retrying a serialization failure.
type: how-to
product: database
summary: Grouping statements so they all land or none do, and what to do when Postgres makes you retry.
updated: 2026-09-28
order: 2
---

A database is Postgres, so a transaction is Postgres' transaction: every
statement inside it lands together or none of them do.

## Transaction basics

<CodeTabs>
	<CodeTab
		label="Drizzle"
		language="ts"
		code={`import { db } from '@/lib/db'
import { accounts, transactions } from '@/lib/db/schema'
import { eq, sql } from 'drizzle-orm'

// Basic transaction with automatic rollback on error
await db.transaction(async (tx) => {
  // Debit from source account
  await tx.update(accounts)
    .set({ balance: sql\`\${accounts.balance} - 100\` })
    .where(eq(accounts.id, sourceAccountId))

  // Credit to destination account
  await tx.update(accounts)
    .set({ balance: sql\`\${accounts.balance} + 100\` })
    .where(eq(accounts.id, destAccountId))

  // Record the transaction
  await tx.insert(transactions).values({
    fromAccountId: sourceAccountId,
    toAccountId: destAccountId,
    amount: 100,
  })
})
// If any operation fails, ALL changes are rolled back`}
	/>
	<CodeTab
		label="SQL"
		language="sql"
		code={`BEGIN;

UPDATE accounts SET balance = balance - 100 WHERE id = $1;
UPDATE accounts SET balance = balance + 100 WHERE id = $2;
INSERT INTO transactions (from_account_id, to_account_id, amount)
VALUES ($1, $2, 100);

COMMIT;`}
	/>
</CodeTabs>

<Callout tone="note" title="Automatic commit and rollback">
The callback returning commits; a thrown error rolls back. That is the whole
contract — there is no explicit `commit()` to forget.
</Callout>

## Return values

The callback's return value is the transaction's result, so a transaction can
create several rows and hand them back:

```ts
import { db } from '@/lib/db'
import { users, profiles } from '@/lib/db/schema'

// Return created records
const result = await db.transaction(async (tx) => {
  const [user] = await tx.insert(users).values({
    email: 'user@example.com',
    name: 'John Doe',
  }).returning()

  const [profile] = await tx.insert(profiles).values({
    userId: user.id,
    bio: 'Hello world!',
  }).returning()

  return { user, profile }
})

console.log('Created user:', result.user.id)
console.log('Created profile:', result.profile.id)
```

## Manual rollback

There is no rollback call: throw, and the transaction rolls back. A rule the
database cannot express — "the balance must cover the transfer" — is a check
in the callback:

```ts
import { db } from '@/lib/db'
import { accounts } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'

async function transferMoney(fromId: string, toId: string, amount: number) {
  return await db.transaction(async (tx) => {
    // Check source account balance
    const [source] = await tx.select()
      .from(accounts)
      .where(eq(accounts.id, fromId))

    if (source.balance < amount) {
      // Throw to trigger rollback
      throw new Error('Insufficient funds')
    }

    // Proceed with transfer
    await tx.update(accounts)
      .set({ balance: source.balance - amount })
      .where(eq(accounts.id, fromId))

    await tx.update(accounts)
      .set({ balance: sql`${accounts.balance} + ${amount}` })
      .where(eq(accounts.id, toId))

    return { success: true, newBalance: source.balance - amount }
  })
}

// Usage with error handling
try {
  const result = await transferMoney(fromId, toId, 100)
  console.log('Transfer successful:', result.newBalance)
} catch (error) {
  console.error('Transfer failed:', (error instanceof Error ? error.message : String(error)))
}
```

## Isolation levels

Postgres defaults to read committed, which is right for most work. Pick a
stronger level when a decision depends on rows another transaction could
change underneath it.

<PropertyTable
	properties={[
		{
			name: 'read uncommitted',
			type: 'string',
			description: 'Accepted by Postgres and treated as read committed; a transaction never sees another transaction’s uncommitted rows.',
		},
		{
			name: 'read committed',
			type: 'string',
			description: 'The default. Each statement sees a fresh snapshot, so a row can change between two statements in the same transaction.',
		},
		{
			name: 'repeatable read',
			type: 'string',
			description: 'One snapshot for the whole transaction: the same query returns the same rows, and a write to a row that changed since the snapshot fails.',
		},
		{
			name: 'serializable',
			type: 'string',
			description: 'Repeatable read plus a check that the outcome matches some serial order. The strongest, and the one that most often asks for a retry.',
		},
	]}
/>

```ts
import { db } from '@/lib/db'

// Set isolation level for a transaction
await db.transaction(async (tx) => {
  // Your queries here run with serializable isolation
  const inventory = await tx.select()
    .from(products)
    .where(eq(products.id, productId))

  if (inventory[0].stock < quantity) {
    throw new Error('Out of stock')
  }

  await tx.update(products)
    .set({ stock: inventory[0].stock - quantity })
    .where(eq(products.id, productId))
}, {
  isolationLevel: 'serializable',
})

// Read committed (default) - good for most cases
await db.transaction(async (tx) => {
  // Queries
}, {
  isolationLevel: 'read committed',
})

// Repeatable read - same query returns same data
await db.transaction(async (tx) => {
  // Queries
}, {
  isolationLevel: 'repeatable read',
})
```

<Callout tone="warning" title="A stronger level means retries">
At repeatable read and serializable, Postgres may abort a transaction rather
than allow a result that no serial order could produce. The abort is a
`40001` serialization failure and it is not a bug in your code: the
transaction's work is discarded, and the caller runs it again.
</Callout>

## Retry a serialization failure

Wrap the transaction, retry only on `40001`, and back off between attempts:

```ts
import { db } from '@/lib/db'

async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  delay = 100
): Promise<T> {
  let lastError: Error | null = null

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error as Error

      // Check if it's a serialization failure (PostgreSQL error code 40001)
      if (error.code === '40001') {
        console.log(`Retry attempt ${attempt + 1} after serialization failure`)
        await new Promise(r => setTimeout(r, delay * Math.pow(2, attempt)))
        continue
      }

      // Re-throw non-serialization errors immediately
      throw error
    }
  }

  throw lastError
}

// Usage
const result = await withRetry(() =>
  db.transaction(async (tx) => {
    // Critical inventory operation
    const [product] = await tx.select()
      .from(products)
      .where(eq(products.id, productId))

    if (product.stock < 1) {
      throw new Error('Out of stock')
    }

    return await tx.update(products)
      .set({ stock: product.stock - 1 })
      .where(eq(products.id, productId))
      .returning()
  }, { isolationLevel: 'serializable' })
)
```

## Nested transactions (savepoints)

A transaction started inside a transaction is a savepoint: it can roll back
on its own while the outer transaction carries on.

```ts
import { db } from '@/lib/db'
import { orders, orderItems, inventory } from '@/lib/db/schema'

await db.transaction(async (tx) => {
  // Create the order
  const [order] = await tx.insert(orders).values({
    userId,
    status: 'pending',
  }).returning()

  // Process each item with individual savepoints
  for (const item of cartItems) {
    try {
      // Nested transaction creates a savepoint
      await tx.transaction(async (nested) => {
        // Check and reserve inventory
        const [inv] = await nested.select()
          .from(inventory)
          .where(eq(inventory.productId, item.productId))

        if (inv.available < item.quantity) {
          throw new Error(`Insufficient inventory for ${item.productId}`)
        }

        await nested.update(inventory)
          .set({ available: inv.available - item.quantity })
          .where(eq(inventory.productId, item.productId))

        await nested.insert(orderItems).values({
          orderId: order.id,
          productId: item.productId,
          quantity: item.quantity,
          price: item.price,
        })
      })
    } catch (error) {
      // Savepoint rolled back, but outer transaction continues
      console.log(`Skipping item: ${(error instanceof Error ? error.message : String(error))}`)
      // Could add to a "failed items" list
    }
  }

  // Update order total
  await tx.update(orders)
    .set({ status: 'confirmed' })
    .where(eq(orders.id, order.id))
})
```

<Callout tone="note" title="What a savepoint does and does not do">
Rolling back to a savepoint undoes the statements after it — the outer
transaction keeps its earlier work and stays open. It does not release locks
taken inside the savepoint, and it does not make the outer transaction
durable: only its commit does that.
</Callout>

## Error handling patterns

Distinguish the failures you can answer for from the ones you cannot, and let
the rest through:

```ts
import { db } from '@/lib/db'

// Custom error types for transaction failures
class InsufficientFundsError extends Error {
  constructor(public available: number, public required: number) {
    super(`Insufficient funds: need ${required}, have ${available}`)
    this.name = 'InsufficientFundsError'
  }
}

class ConcurrencyError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'ConcurrencyError'
  }
}

// Transaction with typed error handling
async function processPayment(userId: string, amount: number) {
  try {
    return await db.transaction(async (tx) => {
      const [account] = await tx.select()
        .from(accounts)
        .where(eq(accounts.userId, userId))

      if (!account) {
        throw new Error('Account not found')
      }

      if (account.balance < amount) {
        throw new InsufficientFundsError(account.balance, amount)
      }

      // Version check for optimistic locking
      const updated = await tx.update(accounts)
        .set({
          balance: account.balance - amount,
          version: account.version + 1,
        })
        .where(and(
          eq(accounts.userId, userId),
          eq(accounts.version, account.version)
        ))
        .returning()

      if (updated.length === 0) {
        throw new ConcurrencyError('Account was modified by another transaction')
      }

      return updated[0]
    })
  } catch (error) {
    if (error instanceof InsufficientFundsError) {
      return { success: false, reason: 'insufficient_funds', ...error }
    }
    if (error instanceof ConcurrencyError) {
      return { success: false, reason: 'concurrency', message: (error instanceof Error ? error.message : String(error)) }
    }
    throw error // Re-throw unexpected errors
  }
}
```

## Best practices

Whatever the transaction does, it should be the only thing deciding whether
the caller's world changes.

```ts
// BAD: Side effect inside transaction
await db.transaction(async (tx) => {
  await tx.insert(orders).values(order)
  await sendEmail(user.email, 'Order confirmed') // Can't rollback!
})

// GOOD: Side effect after transaction
const order = await db.transaction(async (tx) => {
  return await tx.insert(orders).values(order).returning()
})

// Only send email after successful commit
await sendEmail(user.email, 'Order confirmed')
```

- Keep transactions short. Every row they touch is held against other writers
  until they commit.
- Send mail, call a webhook and publish a job after the commit, not inside it.
  Nothing outside Postgres rolls back with it.
- Have the callback return the rows the caller needs, so it does not query
  again for what it just wrote.
- Retry only `40001`. A constraint violation, a bad query or a deadlock is
  not going to pass on a second attempt, and retrying it doubles the damage.
- Read committed at the default is usually enough; raise the level where a
  decision must hold, and pay for it in retries.

<RelatedDocs
	links={[
		{
			href: '/docs/database/query-guide',
			label: 'Query guide',
			description: 'The statements these transactions wrap.',
		},
		{
			href: '/docs/database/optimization',
			label: 'Optimization',
			description: 'Indexes, plans and lock-friendly schema changes.',
		},
		{
			href: '/docs/api/databases',
			label: 'The databases collection',
			description: 'Create, connect to and restore a database.',
		},
	]}
/>
