Prisma $transaction: sequential vs interactive and what can go wrong
Group multiple Prisma operations into one atomic unit, choose between sequential and interactive transactions, and avoid connection-pool traps.
24 Aug 2026, 13:56 UTC

The problem: half-finished work is still finished work
Your API receives an order. You insert the order row, then decrement the inventory count. The inventory call fails because the item is out of stock. Your database now contains an order for something you cannot ship.
Prisma Client’s $transaction API groups multiple database operations into one atomic unit: either every operation succeeds, or the database rolls back to its state before the transaction began. Rollback means the database undoes all changes made during the transaction. This post explains how to choose between the two transaction forms, shows a worked example, and lists the limits you need to design around.
Sequential transactions: independent operations in one array
The sequential form accepts an array of Prisma promises. Prisma sends them as a single transaction. Use this when the operations do not need to read each other’s results.
await prisma.$transaction([
prisma.order.create({
data: { customerId: 'cust_123', total: 4999 },
}),
prisma.inventory.update({
where: { sku: 'SKU-001' },
data: { quantity: { decrement: 1 } },
}),
]);
If the inventory update throws, Prisma rolls back the order creation too. Neither row persists. The limitation is that you cannot branch based on a result produced earlier in the same array; each promise is defined before the transaction starts.
Interactive transactions: conditional logic with a shared client
The interactive form accepts an async function that receives a transactional Prisma client. Use this when one operation depends on the result of another.
await prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({
where: { id: 'order_999' },
});
if (!order || order.status !== 'pending') {
throw new Error('Order is not eligible for processing');
}
await tx.order.update({
where: { id: 'order_999' },
data: { status: 'processing' },
});
await tx.inventory.update({
where: { sku: order.sku },
data: { quantity: { decrement: 1 } },
});
});
The tx client shares the same transaction context. Because the callback holds a database connection for its entire duration, avoid long-running work inside it.
| Sequential | Interactive | |
|---|---|---|
| Input | Array of Prisma promises | Async function receiving transactional client |
| Best for | Independent writes | Conditional or dependent writes |
| Can read prior results? | No | Yes |
| Holds connection | Briefly for the batch | For the whole callback |
| Branching supported? | No | Yes |
Trade-offs and limits
- Connection pool: A connection pool is a cache of database connections reused by your application. An interactive transaction checks out a connection and keeps it until the callback resolves or rejects. If the callback calls an external API, waits on a slow third party, or runs heavy application logic, you can exhaust the pool and stall other requests.
- Side effects are not rolled back: Database rollback reverts rows, but it does not unsend an email, delete an uploaded file, or recall a webhook. Send notifications only after the transaction commits.
- MongoDB differences: MongoDB transactions require a replica set or sharded cluster and have different isolation semantics than PostgreSQL, MySQL, SQLite, or SQL Server.
- Sequential cannot branch: You cannot read a row in operation one and use that value to decide whether operation two runs.
How to verify rollback
Run this check in a development or staging database, not production. Your application needs a database user with write and transaction privilegesfor example, InnoDB tables in MySQL, or default PostgreSQL behavior.
- Start a sequential
$transactionwhere the second operation intentionally throws. - Catch the error so your process does not crash.
- Query the table affected by the first operation.
- Confirm the row from the first operation does not exist.
// Run in a Node.js script against a staging database
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
try {
await prisma.$transaction([
prisma.order.create({
data: { customerId: 'verify_test', total: 100 },
}),
prisma.inventory.update({
where: { sku: 'DOES_NOT_EXIST' },
data: { quantity: { decrement: 1 } },
}),
]);
} catch (err) {
// Expected: the second operation fails
}
const count = await prisma.order.count({
where: { customerId: 'verify_test' },
});
console.log('Orders after failed transaction:', count);
// Confirm this is 0 before relying on transactions in production
await prisma.$disconnect();
If the count is not zero, your connector or configuration is not honoring the transaction boundary. Check your Prisma Client version in package.json and your database engine documentation before using transactions in production.
Closing
Use sequential $transaction for independent writes that must succeed together. Switch to interactive when you need to read, decide, and write inside the same atomic unit. Keep callbacks short, keep side effects outside the transaction, and verify rollback behavior in a non-production database before you rely on it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.