Fix: Prisma Transaction Error — Transaction Already Closed or Rolled Back
Part of: JavaScript & TypeScript Errors
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, and most transaction errors come from confusing the two or mixing their semantics.
The sequential (array) form — $transaction([op1, op2, op3]) — takes an array of Prisma Client method calls and executes them atomically. All operations succeed or all roll back. You can’t put conditional logic, loops, or await statements between them. The array is handed to Prisma’s query engine in one batch.
The interactive form — $transaction(async (tx) => { ... }) — gives you a callback with a transaction-scoped client. You can write arbitrary async logic, reference results from earlier operations, and throw to trigger rollback. The trade-off is a default timeout of 5 seconds. If the callback hasn’t completed by then, Prisma automatically rolls back the transaction and throws. This timeout exists because interactive transactions hold database locks for their entire duration, and a stuck transaction blocks other queries.
The most common mistake is using the global prisma client inside an interactive transaction callback instead of the tx parameter. Operations on prisma run outside the transaction — they won’t roll back if something fails later in the callback. This creates a subtle data integrity bug where half the writes commit and half roll back.
Other common causes:
- Transaction timeout — long operations (complex queries, external API calls) exceed the 5-second default 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.
Diagnostic Timeline
When a transaction error appears, the instinct is to wrap the whole thing in $transaction again or increase the timeout. Walk through these steps to identify the actual failure mode.
Minute 0 — Read the error code. Prisma transaction errors include a code. P2034 means “transaction failed due to a write conflict or a deadlock” — this is a concurrency issue, not a code issue. The timeout error (Unable to start a transaction in the given timeframe) means the transaction exceeded its time limit. A “Transaction already closed” error means your code tried to use the transaction after it was already committed or rolled back.
Minute 2 — Check for prisma vs tx. Search the transaction callback for any use of the global prisma client. Every database operation inside the callback must use the tx parameter. A single prisma.user.update() instead of tx.user.update() runs outside the transaction and won’t roll back. This is the most common bug and the hardest to spot in code review because the code still “works” — it just doesn’t work atomically.
Minute 4 — Identify what’s slow. If the error is a timeout, add console.time / console.timeEnd around each operation inside the callback. The operation that takes the longest is the one pushing you past the 5-second limit. Common culprits: external API calls (Stripe, email), large batch operations in a loop, and complex findMany queries without proper indexes.
Minute 6 — Check for nested transactions. Search for $transaction calls inside the callback. Prisma does not support nested transactions. If a helper function internally calls $transaction, and that helper is called from within another $transaction callback, it throws. Refactor the helper to accept a tx parameter instead of creating its own transaction.
Minute 8 — Check the database for locks. If you get P2034 (conflict), another transaction is holding a lock on the same rows. Run SELECT * FROM pg_stat_activity WHERE state = 'active' (PostgreSQL) or SHOW PROCESSLIST (MySQL) to see concurrent queries. If multiple requests hit the same endpoint simultaneously and each one opens a transaction that reads and writes the same rows, deadlocks are likely. Consider adding retry logic or using optimistic locking.
Minute 10 — Verify isolation level. If the transaction reads a value, makes a decision based on it, and then writes — but another transaction changes the value in between — you have a race condition. Use Serializable isolation for critical operations like balance transfers. Use ReadCommitted (the default) for most other cases where strict ordering is not required.
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: Implement Retry Logic for Conflict Errors
Transactions that fail with P2034 (write conflict) can often succeed on retry. This is expected behavior with Serializable isolation — the database aborts one conflicting transaction so the other can proceed:
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
delay = 100,
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2034' &&
attempt < maxRetries
) {
// Wait with exponential backoff before retrying
await new Promise(resolve => setTimeout(resolve, delay * attempt));
continue;
}
throw error;
}
}
throw new Error('Transaction failed after max retries');
}
// Usage
const result = await withRetry(() =>
prisma.$transaction(async (tx) => {
const account = await tx.account.findUnique({ where: { id } });
if (account.balance < amount) throw new Error('Insufficient funds');
return tx.account.update({
where: { id },
data: { balance: { decrement: amount } },
});
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
})
);Fix 8: 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`,
},
},
});Helper functions that internally call $transaction — if a utility function (like createUserWithProfile) wraps its logic in $transaction, calling it from within another transaction creates a nested transaction, which throws. Refactor the helper to accept an optional tx parameter:
async function createUserWithProfile(
data: CreateUserInput,
tx?: Prisma.TransactionClient,
) {
const client = tx ?? prisma;
const user = await client.user.create({ data: { email: data.email } });
await client.profile.create({ data: { userId: user.id, bio: data.bio } });
return user;
}
// Standalone use — creates its own transaction
await prisma.$transaction(async (tx) => {
await createUserWithProfile(data, tx);
});Transaction works locally but fails in production — check the database provider. PlanetScale (MySQL-compatible) does not support interactive transactions by default because it uses Vitess, which has limited transaction support across shards. Use the array form or enable @@map with single-shard transactions.
For related Prisma issues, see Fix: Prisma N+1 Query Problem, Fix: Prisma Unique Constraint Failed, Fix: Prisma Connection Pool Exhausted, and Fix: Prisma Migration 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.