Skip to content
NestJS ns database 5 min read

Prisma Transactions & Migrations

Real-world data operations rarely touch a single row. When you debit one account and credit another, both writes must succeed or neither should — that is what transactions guarantee. Meanwhile, your schema evolves over time, and Prisma Migrate gives you a version-controlled, reproducible way to evolve your database alongside your code. This page shows how to run batch and interactive transactions through PrismaService and how to manage schema changes from development through production.

Batch transactions with $transaction

The simplest form of $transaction accepts an array of Prisma operations and runs them inside a single database transaction. Every operation commits together, or the whole batch rolls back. This is ideal when you already know all the queries up front and they do not depend on each other’s results.

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class AnalyticsService {
  constructor(private readonly prisma: PrismaService) {}

  async snapshot(userId: number) {
    const [user, postCount, comments] = await this.prisma.$transaction([
      this.prisma.user.findUniqueOrThrow({ where: { id: userId } }),
      this.prisma.post.count({ where: { authorId: userId } }),
      this.prisma.comment.findMany({ where: { authorId: userId } }),
    ]);

    return { user, postCount, comments };
  }
}

Because the array is sent as one unit, all three queries share the same transaction and see a consistent view of the data. The return type is a tuple matching the order of operations, so destructuring stays fully typed.

Interactive transactions

When later writes depend on earlier reads — for example, checking a balance before deducting it — use the callback form. Prisma passes a transactional client (tx) that you must use for every query inside the block. If the callback throws, the transaction rolls back automatically.

import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class WalletService {
  constructor(private readonly prisma: PrismaService) {}

  async transfer(fromId: number, toId: number, amount: number) {
    return this.prisma.$transaction(async (tx) => {
      const sender = await tx.account.findUniqueOrThrow({ where: { id: fromId } });

      if (sender.balance < amount) {
        throw new BadRequestException('Insufficient funds');
      }

      await tx.account.update({
        where: { id: fromId },
        data: { balance: { decrement: amount } },
      });

      return tx.account.update({
        where: { id: toId },
        data: { balance: { increment: amount } },
      });
    });
  }
}

Always use the injected tx client inside the callback — calling this.prisma.account.update(...) would run outside the transaction and break atomicity.

You can tune behaviour with a second options argument:

OptionPurposeDefault
maxWaitMax ms to wait for a connection from the pool2000
timeoutMax ms the interactive transaction may run5000
isolationLevelSQL isolation level (e.g. Serializable)DB default
await this.prisma.$transaction(callback, {
  maxWait: 5000,
  timeout: 10000,
  isolationLevel: 'Serializable',
});

Keep interactive transactions short. They hold a connection and locks for their whole duration, so never await external HTTP calls or slow work inside the callback.

Managing schema with Prisma Migrate

Prisma Migrate turns changes in schema.prisma into SQL migration files committed to your repository. During development, prisma migrate dev diffs your schema against the database, generates a new migration, applies it, and regenerates the client.

model Account {
  id      Int    @id @default(autoincrement())
  email   String @unique
  balance Int    @default(0)
}
npx prisma migrate dev --name add_account_balance

Output:

Applying migration `20260614120000_add_account_balance`

The following migration(s) have been created and applied:
migrations/
  └─ 20260614120000_add_account_balance/
     └─ migration.sql

Your database is now in sync with your schema.
✔ Generated Prisma Client (v6.x) in 84ms

In production or CI you never generate migrations on the fly. Instead, apply the already-committed migrations with migrate deploy, which is non-interactive and never resets data.

npx prisma migrate deploy
CommandEnvironmentBehaviour
migrate devLocal developmentCreates + applies migrations, may reset on drift
migrate deployProduction / CIApplies pending migrations only, never resets
migrate resetLocal developmentDrops the DB, reapplies all migrations, runs seed
migrate statusAnyReports applied vs. pending migrations

Seeding the database

Prisma runs a seed script after migrate reset and on demand via prisma db seed. Configure the command in package.json, then write idempotent seeds with upsert so reruns stay safe.

{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  await prisma.account.upsert({
    where: { email: '[email protected]' },
    update: {},
    create: { email: '[email protected]', balance: 1000 },
  });
}

main()
  .then(() => prisma.$disconnect())
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });
npx prisma db seed

Resolving migration drift

Drift happens when the actual database schema diverges from what your migration history expects — usually from a manual change or an out-of-band edit. prisma migrate status detects it, and in development migrate dev will offer to reset.

Output:

Drift detected: Your database schema is not in sync with your migration history.
[+] Added column `phone` on table `Account`

In production you cannot reset. Instead, baseline or mark migrations resolved so the history matches reality:

# Mark a failed migration as rolled back so it can be reapplied
npx prisma migrate resolve --rolled-back 20260614120000_add_account_balance

# Mark an already-applied migration as applied (e.g. when baselining)
npx prisma migrate resolve --applied 20260614120000_add_account_balance

To baseline an existing production database, generate the initial migration from the current schema, then mark it applied so Migrate does not try to recreate the tables.

Best Practices

  • Prefer the array form of $transaction for independent writes; reserve the interactive form for read-then-write logic.
  • Inside interactive transactions, only ever use the provided tx client — never the outer PrismaService.
  • Keep transactions short and never await network I/O inside the callback to avoid connection-pool starvation.
  • Run migrate dev locally to author migrations and migrate deploy in CI/production — never migrate dev against production.
  • Commit the entire prisma/migrations folder to version control so history is reproducible across environments.
  • Write seeds with upsert so they are idempotent and safe to rerun after a reset.
  • Use migrate status in your deployment pipeline to fail fast on drift or pending migrations.
Last updated June 14, 2026
Was this helpful?