Skip to content

Fix: Prisma Transaction Error — Transaction Already Closed or Rolled Back

FixDevs · (Updated: )

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 $transaction array — the array form only accepts Prisma Client calls, not arbitrary Promises or async operations.
  • Nested $transaction calls — calling tx.$transaction() or prisma.$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 back

Use 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 used

Still 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles