Why transactions matter in production systems
Imagine an order flow where you create an order record and then decrement stock. If the stock update fails halfway through, you've got an orphaned order with no inventory deduction—a nightmare for reconciliation. Transactions in Prisma: a practical guide for developers is your shield against these scenarios. Prisma gives us two powerful transaction modes, each suited to different problems, and understanding when to use each one keeps your data consistent when it counts.
We've all shipped code that works fine locally, only to discover race conditions or partial writes in production under real load. Transactions force us to think about atomicity upfront, and Prisma makes that straightforward once you know the patterns.
Batch transactions for independent writes
The simplest transaction pattern is batch mode: you pass an array of Prisma operations, and Prisma executes them in a single database transaction. If any one fails, all roll back.
await prisma.$transaction([
prisma.order.create({
data: {
userId,
total: 99.99,
status: 'pending',
},
}),
prisma.inventory.updateMany({
where: { productId: { in: productIds } },
data: { reserved: { increment: 1 } },
}),
prisma.auditLog.create({
data: {
action: 'ORDER_CREATED',
userId,
timestamp: new Date(),
},
}),
]);
Batch transactions shine when your operations don't depend on each other's results. You're not using the ID from one operation to inform the next. The database handles the orchestration: all-or-nothing, no race conditions between statements.
Real-world example: an e-commerce checkout. You create the order, reserve inventory across multiple SKUs, and log an audit entry—three independent writes. Batch mode is clean, fast, and zero overhead.
One critical constraint: you can't use the result of one operation as input to another in batch mode. If you need that, you move to interactive transactions.
Interactive transactions for dependent logic
When operations depend on each other—when you need to read a result, make a decision, then write again—you use interactive transactions. These take an async callback that receives a transaction client:
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
userId,
items: itemCount,
status: 'pending',
},
});
await tx.stock.update({
where: { id: productId },
data: { qty: { decrement: itemCount } },
});
await tx.orderHistory.create({
data: {
orderId: order.id,
action: 'CREATED',
timestamp: new Date(),
},
});
});
Here we're using the order ID returned from the create to write the history record. The transaction client tx looks and feels exactly like the global prisma client, but every query runs inside the same database transaction. If anything throws, the entire block rolls back.
Another practical scenario: payment processing. You fetch the user's wallet balance, check if they have enough funds, deduct the amount, create a transaction record, and update their tier based on new spend. Each step depends on the previous one. Interactive transactions handle this elegantly:
await prisma.$transaction(async (tx) => {
const wallet = await tx.wallet.findUnique({
where: { userId },
});
if (!wallet || wallet.balance < amount) {
throw new Error('Insufficient funds');
}
await tx.wallet.update({
where: { userId },
data: { balance: { decrement: amount } },
});
const payment = await tx.payment.create({
data: { userId, amount, status: 'completed' },
});
const newSpend = wallet.balance - amount;
if (newSpend > 10000) {
await tx.user.update({
where: { id: userId },
data: { tier: 'gold' },
});
}
return payment;
});
The entire flow is atomic. If the tier update fails, the wallet deduction and payment record are rolled back too. No partial state.
Keep interactive transactions short
Interactive transactions hold a database connection open for the duration of your callback. If your callback takes 5 seconds because you're calling an external API, sleeping, or doing heavy computation, you're holding a connection the database could be using for other queries. Under load, this tanks throughput.
The anti-pattern:
// BAD: Calling external API inside transaction
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data });
// This can take 2-5 seconds, blocking a connection
const paymentGatewayResponse = await callPaymentAPI(order.id);
await tx.order.update({
where: { id: order.id },
data: { paymentId: paymentGatewayResponse.id },
});
});
Better pattern: do the API call outside, then use the result in a tight transaction:
const order = await prisma.order.create({ data });
// Call API outside transaction
const paymentGatewayResponse = await callPaymentAPI(order.id);
// Short, focused transaction
await prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: order.id },
data: { paymentId: paymentGatewayResponse.id, status: 'paid' },
});
await tx.inventory.updateMany({
where: { orderId: order.id },
data: { status: 'allocated' },
});
});
Interactive transactions should contain only database operations. Fetch data, compute, validate—do it outside. Then lock in the writes.
Set isolation level when concurrency matters
By default, Prisma uses your database's default isolation level (typically READ_COMMITTED on PostgreSQL). Under high concurrency, this can allow dirty reads or lost updates. If two requests simultaneously run overlapping transactions, you might see stale data.
For critical sections—payments, inventory allocation, ledger entries—you can raise the isolation level:
await prisma.$transaction(
async (tx) => {
const account = await tx.account.findUnique({
where: { id: accountId },
});
// Transfer logic
await tx.account.update({
where: { id: accountId },
data: { balance: { decrement: amount } },
});
await tx.account.update({
where: { id: recipientId },
data: { balance: { increment: amount } },
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
);
Isolation levels, from weakest to strongest:
- ReadUncommitted: Almost never used in practice.
- ReadCommitted: Default. Prevents dirty reads but allows non-repeatable reads.
- RepeatableRead: Guarantees the same data across multiple reads within a transaction.
- Serializable: Strongest. Transactions execute as if sequentially, no concurrency anomalies. Performance cost is real.
For a money transfer, Serializable is appropriate. For an order creation, ReadCommitted is usually fine. Know your domain's tolerance for edge cases.
PostgreSQL uses serializable snapshot isolation (SSI) for Serializable level, which is clever and doesn't lock as aggressively as true serialization. MySQL's Serializable uses actual locking. Test under load on your target database.
Common mistakes
- Mixing batch and interactive syntax: You can't pass both an array and a callback to
$transaction. Pick one. - Assuming batch order matters: Batch transactions don't guarantee execution order across databases. If order is critical, use interactive.
- Long-running logic in interactive transactions: Network calls, sleeps, or complex computation inside the callback blocks a database connection.
- Ignoring isolation levels: The default is often fine, but don't assume it's safe for financial or inventory operations without testing concurrent scenarios.
- Forgetting error handling: If your transaction throws, Prisma rolls back, but your application still needs to catch and respond appropriately.
Key takeaways
- Use batch transactions (
$transaction([...])) for independent writes—fast, no overhead. - Use interactive transactions (
$transaction(async tx => ...)) when operations depend on each other's results. - Keep interactive transaction callbacks short and database-only. Call APIs, validate, compute outside; lock in the writes inside.
- Understand your isolation level. Default
ReadCommittedworks for most workflows, but critical paths (payments, inventory) may needRepeatableReadorSerializable. - Test transactions under concurrent load on your target database—theory and practice diverge when real traffic hits.






Comments
Loading comments…