Menu
Platform
AI
App store purchases
Credits
Database
Flags
Generated Assets
Monitoring
Notifications
Organizations
Payments
- Payments
- Payments quickstart
- Checkout policies
- Checkout sessions
- Customer subscriptions
- Entitlement grants
- Exchange rates
- Licence keys
- Licence tokens
- Merchant accounts
- Portal sessions
- Price catalogs
- Revenue
- Revenue facts
- Revenue sources
- Tax threshold statuses
- Usage events
- Usage invoices
- Usage meter prices
- Usage meters
- Usage reservations
- Usage settings
- Usage tiers
Queues
Realtime
Sandboxes
Webhooks
Getting Started
Authentication
KV Store
Deploy & Infrastructure
Reference
Transactions
Grouping statements so they all land or none do, and what to do when Postgres makes you retry.
A database is Postgres, so a transaction is Postgres' transaction: every statement inside it lands together or none of them do.
#Transaction basics
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 backAutomatic commit and rollback
The callback returning commits; a thrown error rolls back. That is the whole
contract — there is no explicit commit() to forget.
#Return values
The callback's return value is the transaction's result, so a transaction can create several rows and hand them back:
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:
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.
| Field | Type | What it is |
|---|---|---|
read uncommitted | string | Accepted by Postgres and treated as read committed; a transaction never sees another transaction’s uncommitted rows. |
read committed | string | The default. Each statement sees a fresh snapshot, so a row can change between two statements in the same transaction. |
repeatable read | string | 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. |
serializable | string | Repeatable read plus a check that the outcome matches some serial order. The strongest, and the one that most often asks for a retry. |
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',
})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.
#Retry a serialization failure
Wrap the transaction, retry only on 40001, and back off between attempts:
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.
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))
})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.
#Error handling patterns
Distinguish the failures you can answer for from the ones you cannot, and let the rest through:
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.
// 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.