Fix: Prisma Transaction Error — Transaction Already Closed or Rolled Back
Quick Answer
How to fix Prisma transaction errors — interactive transactions vs $transaction array, error handling and rollback, nested transactions, timeout issues, and isolation levels.
The Error
Prisma throws an error when using transactions:
PrismaClientKnownRequestError: Transaction already closed: A commit cannot be executed on a
transaction that has been rolled back or committed.Or operations fail inside an interactive transaction:
Error:
Invalid `prisma.user.create()` invocation:
Transaction API error: Unable to start a transaction in the given timeframe.
The transaction was already closed due to a timeout. Please increase the timeout for the transaction
or create a shorter transaction.Or a nested transaction attempt throws:
Error: A transaction cannot be started within a transaction:Or the $transaction array form doesn’t work as expected — changes aren’t committed together:
await prisma.$transaction([
prisma.user.create({ data: userData }),
prisma.account.create({ data: accountData }),
]);
// If account.create fails, user.create is NOT rolled back in array form? (myth — it is)Why This Happens
Prisma has two transaction patterns with different behaviors:
- Sequential operations (
$transaction([...])) — an array of Prisma operations run atomically. All succeed or all roll back. Noasync/awaitinside — just an array of Prisma Client method calls. - Interactive transactions (
$transaction(async (tx) => {...})) — a callback with a transaction-scoped clienttx. Allows conditional logic between operations. Has a timeout (default 5 seconds).
Common mistakes:
- Using the original
prismaclient inside an interactive transaction — operations on the globalprismaclient run outside the transaction. Use thetxparameter instead. - Transaction timeout — interactive transactions have a 5-second default. Long operations (complex queries, external API calls) exceed this and the transaction is automatically rolled back.
- Awaiting non-Prisma promises inside
$transactionarray — the array form only accepts Prisma Client calls, not arbitrary Promises or async operations. - Nested
$transactioncalls — callingtx.$transaction()orprisma.$transaction()inside an interactive transaction callback throws an error. - Transaction not awaited — not awaiting
$transaction()means the transaction might not complete before the next operation.
Fix 1: Use the Right Transaction Pattern
Array form — for independent atomic operations:
// CORRECT — array of Prisma operations (no async/await, no conditional logic)
const [user, account] = await prisma.$transaction([
prisma.user.create({
data: { email: '[email protected]', name: 'Alice' },
}),
prisma.account.create({
data: { userId: 'will-be-set-by-db', balance: 1000 },
// ⚠️ Can't reference the user.id created above — use interactive tx for that
}),
]);Interactive form — for dependent operations:
// CORRECT — interactive transaction with tx client
const result = await prisma.$transaction(async (tx) => {
// Use tx, NOT prisma — operations must be within the same transaction
const user = await tx.user.create({
data: { email: '[email protected]', name: 'Alice' },
});
// Can reference user.id created above
const account = await tx.account.create({
data: { userId: user.id, balance: 1000 },
});
return { user, account };
});
console.log(result.user.id, result.account.id);WRONG — using prisma (global client) instead of tx inside interactive transaction:
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: userData });
// WRONG — prisma.account.create runs OUTSIDE the transaction
// If this fails, the user created above is NOT rolled back
await prisma.account.create({
data: { userId: user.id, balance: 1000 },
});
});Fix 2: Handle Transaction Timeouts
Interactive transactions default to a 5-second timeout. Long operations need an explicit timeout:
// Check current default (5000ms)
// Configure per-transaction
const result = await prisma.$transaction(
async (tx) => {
// Long-running operations
const users = await tx.user.findMany();
for (const user of users) {
await tx.account.update({
where: { userId: user.id },
data: { /* complex update */ },
});
}
return users;
},
{
timeout: 30000, // 30 seconds (default: 5000ms)
maxWait: 5000, // Max time to wait for transaction to start (default: 2000ms)
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
);Configure default timeout globally:
const prisma = new PrismaClient({
transactionOptions: {
timeout: 10000, // 10 seconds for all transactions
maxWait: 5000,
},
});Warning: Increasing timeout is a band-aid. Long-running transactions hold database locks and reduce concurrency. Instead, break the operation into smaller transactions or move bulk work to a background job.
Avoid slow operations inside transactions:
// WRONG — external API call inside transaction holds DB lock for the API duration
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderData });
const paymentResult = await stripe.charges.create({ amount: order.total }); // External API call
await tx.order.update({ where: { id: order.id }, data: { paid: true } });
});
// CORRECT — external call outside the transaction
const order = await prisma.order.create({ data: { ...orderData, status: 'pending' } });
const paymentResult = await stripe.charges.create({ amount: order.total });
// Short transaction just to update status
await prisma.order.update({
where: { id: order.id },
data: { status: 'paid', stripeChargeId: paymentResult.id },
});Fix 3: Handle Transaction Errors and Rollback
Transactions automatically roll back if an exception is thrown inside the callback. Re-throw errors or throw custom ones to trigger rollback:
async function transferFunds(fromId: string, toId: string, amount: number) {
return prisma.$transaction(async (tx) => {
// Check source account has sufficient funds
const source = await tx.account.findUnique({ where: { id: fromId } });
if (!source) throw new Error('Source account not found');
if (source.balance < amount) {
throw new Error(`Insufficient funds: balance is ${source.balance}`);
// Throwing here rolls back the entire transaction
}
// Debit source
await tx.account.update({
where: { id: fromId },
data: { balance: { decrement: amount } },
});
// Credit destination
await tx.account.update({
where: { id: toId },
data: { balance: { increment: amount } },
});
// Log the transfer
return tx.transfer.create({
data: { fromId, toId, amount, timestamp: new Date() },
});
// If we reach here without throwing, transaction commits
});
}
// Caller handles errors
try {
await transferFunds('acc-1', 'acc-2', 100);
} catch (error) {
if (error.message.includes('Insufficient funds')) {
// Handle business logic error
} else {
// Handle unexpected error
throw error;
}
}Conditional rollback — use a sentinel error:
class RollbackError extends Error {
constructor(public readonly data: any) {
super('Intentional rollback');
}
}
async function dryRunTransaction(operations: () => Promise<any>) {
try {
await prisma.$transaction(async (tx) => {
const result = await operations();
// Throw to rollback — we only wanted to preview the changes
throw new RollbackError(result);
});
} catch (error) {
if (error instanceof RollbackError) {
return error.data; // Return the result without committing
}
throw error;
}
}Fix 4: Fix the Array Transaction Form
The array form of $transaction only accepts Prisma Client method calls — not async functions, Promises, or conditionals:
// WRONG — conditional logic not possible in array form
const ops = [
prisma.user.create({ data: userData }),
];
if (createAccount) {
ops.push(prisma.account.create({ data: accountData }));
}
await prisma.$transaction(ops); // This actually works — array can be dynamic
// WRONG — async operations not supported in array form
await prisma.$transaction([
prisma.user.create({ data: userData }),
someAsyncFunction(), // ← Not a Prisma operation — may not be included in transaction
]);
// CORRECT — use interactive transaction for any logic
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: userData });
if (createAccount) {
await tx.account.create({ data: { userId: user.id, ...accountData } });
}
});Performance of array vs interactive transactions:
The array form sends all operations in a single database round trip — it’s faster for simple cases. The interactive form requires multiple round trips but allows complex logic. Use the array form when you don’t need to reference results between operations.
Fix 5: Transactions with Nested Writes (Prisma’s Nested Queries)
Prisma supports nested writes that are automatically transactional — no explicit $transaction needed:
// These are atomic without $transaction — Prisma handles it
const user = await prisma.user.create({
data: {
email: '[email protected]',
name: 'Alice',
// Nested create — part of the same transaction automatically
profile: {
create: { bio: 'Developer', avatar: 'alice.jpg' },
},
// Nested createMany
posts: {
createMany: {
data: [
{ title: 'First post', content: 'Hello world' },
{ title: 'Second post', content: 'Another post' },
],
},
},
},
include: { profile: true, posts: true },
});
// If any nested create fails, the entire operation is rolled backUse nested writes instead of explicit transactions when the data is related through Prisma’s schema relations.
Fix 6: Choose the Right Isolation Level
The isolation level determines how the transaction interacts with concurrent transactions:
import { Prisma } from '@prisma/client';
// Read Committed (default for most databases) — may see committed changes from other transactions
await prisma.$transaction(async (tx) => { ... }, {
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
});
// Repeatable Read — same rows return same data throughout the transaction
await prisma.$transaction(async (tx) => { ... }, {
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
});
// Serializable — strictest — transactions appear to execute serially
// Use for financial transactions where double-spending must be prevented
await prisma.$transaction(async (tx) => {
const account = await tx.account.findUnique({ where: { id } });
if (account.balance < amount) throw new Error('Insufficient funds');
await tx.account.update({ where: { id }, data: { balance: { decrement: amount } } });
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});Higher isolation = fewer anomalies but more lock contention. Use Serializable for critical financial operations. Use ReadCommitted (default) for most other cases.
Fix 7: Debug Transaction Issues
Log all Prisma queries including transactions:
const prisma = new PrismaClient({
log: [
{ emit: 'event', level: 'query' },
],
});
prisma.$on('query', (e) => {
console.log('Query:', e.query);
console.log('Params:', e.params);
console.log('Duration:', e.duration, 'ms');
});Look for BEGIN and COMMIT/ROLLBACK in the logs to confirm transactions start and end correctly.
Common patterns to verify:
-- Logs should show:
BEGIN -- Transaction started
SELECT * FROM "User" WHERE id = 1 -- Operations within tx
UPDATE "Account" SET balance = ... -- More operations
COMMIT -- Transaction committed
-- If you see ROLLBACK instead of COMMIT — an error occurred
-- If you see no BEGIN/COMMIT — transactions aren't being usedStill Not Working?
Prisma version compatibility — $transaction interactive API was added in Prisma 2.10.0. Earlier versions only support the array form.
SQLite limitations — SQLite doesn’t support concurrent transactions well. If you’re using SQLite (common in testing), use Prisma.TransactionIsolationLevel.Serializable or restructure tests to run serially.
Connection pool exhaustion during transactions — each interactive transaction holds a database connection for its duration. If all pool connections are held by long transactions, new transactions can’t start (maxWait timeout). Reduce transaction duration or increase pool size:
const prisma = new PrismaClient({
datasources: {
db: {
url: `${process.env.DATABASE_URL}?connection_limit=20`,
},
},
});For related Prisma issues, see Fix: Prisma N+1 Query Problem and Fix: Prisma Unique Constraint Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Drizzle ORM Not Working — Schema Out of Sync, Relation Query Fails, or Migration Error
How to fix Drizzle ORM issues — schema definition, drizzle-kit push vs migrate, relation queries with, transactions, type inference, and common PostgreSQL/MySQL configuration problems.
Fix: Prisma Enum Not Working — Invalid Enum Value or Enum Not Recognized
How to fix Prisma enum errors — schema definition, database sync, TypeScript enum type mismatch, filtering by enum, and migrating existing enum values.
Fix: Prisma Connection Pool Exhausted — Can't Acquire Connection from Pool
How to fix Prisma connection pool errors — pool size configuration, connection leaks, serverless deployments, singleton pattern, query timeout, and pgBouncer integration.
Fix: TypeORM QueryFailedError and Entity Not Found
How to fix TypeORM QueryFailedError, entity not found errors, relation issues, missing migrations, and connection configuration problems in Node.js and NestJS applications.