Skip to content

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

FixDevs ·

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:

  1. Sequential operations ($transaction([...])) — an array of Prisma operations run atomically. All succeed or all roll back. No async/await inside — just an array of Prisma Client method calls.
  2. Interactive transactions ($transaction(async (tx) => {...})) — a callback with a transaction-scoped client tx. Allows conditional logic between operations. Has a timeout (default 5 seconds).

Common mistakes:

  • Using the original prisma client inside an interactive transaction — operations on the global prisma client run outside the transaction. Use the tx parameter 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 $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.

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: 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`,
    },
  },
});

For related Prisma issues, see Fix: Prisma N+1 Query Problem and Fix: Prisma Unique Constraint 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