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
txclient inside the callback — callingthis.prisma.account.update(...)would run outside the transaction and break atomicity.
You can tune behaviour with a second options argument:
| Option | Purpose | Default |
|---|---|---|
maxWait | Max ms to wait for a connection from the pool | 2000 |
timeout | Max ms the interactive transaction may run | 5000 |
isolationLevel | SQL 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
| Command | Environment | Behaviour |
|---|---|---|
migrate dev | Local development | Creates + applies migrations, may reset on drift |
migrate deploy | Production / CI | Applies pending migrations only, never resets |
migrate reset | Local development | Drops the DB, reapplies all migrations, runs seed |
migrate status | Any | Reports 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
$transactionfor independent writes; reserve the interactive form for read-then-write logic. - Inside interactive transactions, only ever use the provided
txclient — never the outerPrismaService. - Keep transactions short and never await network I/O inside the callback to avoid connection-pool starvation.
- Run
migrate devlocally to author migrations andmigrate deployin CI/production — nevermigrate devagainst production. - Commit the entire
prisma/migrationsfolder to version control so history is reproducible across environments. - Write seeds with
upsertso they are idempotent and safe to rerun after a reset. - Use
migrate statusin your deployment pipeline to fail fast on drift or pending migrations.