Transactions
Run several writes so they all succeed or all fail together with db.transaction().
Run several writes as one unit with db.transaction(...): they all commit together, or they all roll back.
Prisma ORM 8 runs on PostgreSQL, SQLite, and MongoDB, but there is no MySQL support. The examples on this page are PostgreSQL; db.transaction(...) works the same way on SQLite, and MongoDB is covered in Transactions on MongoDB.
Run writes in a transaction
Use a transaction whenever one business operation spans more than one write: creating a user with their first records, moving a value between two rows, or deleting a parent after its children. Pass a callback to db.transaction(...), and inside it, query through tx instead of db. tx.orm is the model API, what prisma.user was in Prisma ORM 7; tx.sql is the SQL query builder, and both work the same as db.orm and db.sql. Every query you make on tx runs inside the transaction, and db.transaction(...) returns whatever your callback returns:
import { db } from "./prisma/db";
const result = await db.transaction(async (tx) => {
const user = await tx.orm.public.User.create({ email: "jane@prisma.io", name: "Jane" });
const post = await tx.orm.public.Post.create({ title: "Hello", published: false, authorId: user.id });
return { userId: user.id, postId: post.id }; // both records exist once this returns
});db is the client you create once in src/prisma/db.ts, and prisma orm init writes that file for you. It writes the file once; it is your code, so edit it freely. Under tx.orm.public, the property name is the model name, so User and Post are spelled exactly as you spelled them in your contract.
public is the PostgreSQL schema, the namespace your tables live in, which is public unless you set one. create(...) takes the fields of the record directly, with no data: wrapper: pass a field if it is required and has no default, while everything else is optional, and Write data has the full rules. You don't need a transaction for a single write; every mutation is already atomic on its own.
Read inside a transaction
Reads work the same way as writes: query through tx.orm and you see the rows the transaction has already written. .first() returns null when nothing matches.
await db.transaction(async (tx) => {
await tx.orm.public.User.create({ email: "jane@prisma.io", name: "Jane" });
const jane = await tx.orm.public.User.where({ email: "jane@prisma.io" }).first();
// jane is the record created a line earlier, before the transaction commits
});Pass tx to your helper functions
A helper that runs inside the caller's transaction takes tx as a parameter:
import { db, type Tx } from "./prisma/db";
async function createWelcomePost(tx: Tx, authorId: string) {
return tx.orm.public.Post.create({ title: "Welcome", published: false, authorId });
}
await db.transaction(async (tx) => {
const user = await tx.orm.public.User.create({ email: "jane@prisma.io", name: "Jane" });
await createWelcomePost(tx, user.id);
});Prisma ORM does not export a type for tx, so add this line to src/prisma/db.ts: export type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];, then import Tx wherever a helper needs it.
Do not open a second transaction inside the helper, because calling db.transaction(...) again inside a callback does not nest: the inner call is a separate transaction that commits or rolls back on its own, and the outer one cannot roll it back. Pass tx down instead.
Options and isolation level
db.transaction(...) takes the callback and nothing else. There are no isolationLevel, timeout, or maxWait options; see what is not available yet. Every transaction runs at the database's default isolation level, READ COMMITTED on PostgreSQL, and you cannot ask for another level yet. If you need one, set it yourself as the first statement in the callback:
await db.transaction(async (tx) => {
await tx.execute(db.raw.sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`.affectedCount().build());
// your writes
});There is no transaction timeout either, so a transaction stays open for as long as your callback runs. If you need a limit, set PostgreSQL's idle_in_transaction_session_timeout on the connection (transaction_timeout on PostgreSQL 17 and later).
Roll back on errors
The transaction commits when the callback returns and rolls back when it throws, so nothing inside the callback survives an error:
try {
await db.transaction(async (tx) => {
await tx.orm.public.User.create({ email: "ghost@prisma.io", name: "Ghost" });
throw new Error("boom");
});
} catch {
// The user record was rolled back and does not exist
}Write conflicts
When PostgreSQL cannot commit your transaction because another transaction touched the same rows, it aborts yours. Errors from the database arrive with a sqlState property holding the PostgreSQL error code (40001 for a serialization failure, when two transactions changed the same rows and one had to give way; 40P01 for a deadlock). When the conflict surfaces at commit, the error you catch has the code RUNTIME.TRANSACTION_COMMIT_FAILED and the database error sits on its cause. There is no exported error class to check with instanceof, so test for the property, on the error and on its cause. Prisma ORM does not retry write conflicts for you, so write the retry yourself around the db.transaction(...) call:
async function withRetry<T>(run: () => Promise<T>, attempts = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await run();
} catch (error) {
const source = error instanceof Error && error.cause ? error.cause : error;
const code = typeof source === "object" && source && "sqlState" in source ? source.sqlState : null;
if ((code !== "40001" && code !== "40P01") || attempt === attempts) throw error;
}
}
}
await withRetry(() => db.transaction(async (tx) => { /* your writes */ }));Use the SQL builder in a transaction
The SQL query builder works the same way inside a transaction. tx.sql has the methods db.sql has, and you run the finished query with tx.execute(...):
const cutoff = Temporal.Instant.from("2026-01-01T00:00:00Z");
await db.transaction(async (tx) => {
const query = tx.sql.public.post
.update({ published: false })
.where((f, fns) => fns.lt(f.createdAt, cutoff))
.build();
await tx.execute(query);
});Two things differ from tx.orm. Under tx.sql.public the property name is the table name, so it is tx.sql.public.post where the ORM API has tx.orm.public.Post. The table name is the model name with a lowercase first letter, so the model BlogPost has the table blogPost, and you set @@map on the model to use a different table name.
The second difference is that you chain clauses and then call .build() to finish the query: .build() returns the query without running it, and tx.execute(...) runs it and returns { affectedRows }, the number of rows it changed. The .where(...) callback receives two arguments: f holds the columns, and fns holds the operators (lt is less-than). Advanced queries covers both.
Transactions on MongoDB
Prisma ORM does not support MongoDB transactions yet: there is no db.transaction(...) on the MongoDB client. To run a multi-document transaction today, use the MongoDB driver directly: share one MongoClient between Prisma ORM and your code, and group the writes in a driver session.
MongoDB only runs transactions on a replica set, and a standalone server rejects them. To turn a standalone server into a single-member replica set, follow the MongoDB guide on converting a standalone to a replica set.
Install @prisma/orm-mongo, which a MongoDB project already has, together with version 7 of the mongodb driver:
bun add @prisma/orm-mongo mongodb@7prisma orm init writes src/prisma/db.ts with a url option. Replace that option with a MongoClient you create and export yourself, so your driver code and Prisma ORM use the same connection:
Run npx prisma contract emit once first; db.ts imports the two files it writes.
import "dotenv/config";
import mongo from "@prisma/orm-mongo/runtime";
import { MongoClient } from "mongodb";
import type { Contract } from "./contract.d"; // the two files `prisma contract emit` writes
import contractJson from "./contract.json" with { type: "json" };
export const client = new MongoClient(process.env["DATABASE_URL"]!);
export const db = mongo<Contract>({
contractJson,
mongoClient: client,
dbName: "app",
});Keep the contractJson import and the Contract type, and run prisma contract emit after every change to your contract, on PostgreSQL and on MongoDB alike.
dbName is now the only thing that picks the database, so set it to the database your driver code reads, and client.db("app") and Prisma ORM stay on the same one. An empty dbName is an error.
import { client, db } from "./prisma/db";
const session = client.startSession();
try {
await session.withTransaction(async () => {
const database = client.db("app");
const users = database.collection("users");
const user = await users.insertOne({ email: "jane@prisma.io", name: "Jane" }, { session });
const post = { title: "Hello", published: false, authorId: user.insertedId };
await database.collection("posts").insertOne(post, { session });
});
} finally {
await session.endSession();
}
// Prisma ORM reads see the committed result
const jane = await db.orm.users.where({ email: "jane@prisma.io" }).first();On MongoDB there is no schema segment in the path, and the property name is the collection name. The collection name is the model name with a lowercase first letter, so a model User is db.orm.user. db.orm.users above is the collection name; the model is User with @@map("users").
Writes you make through the driver skip the type-checking that Prisma ORM queries get, so put each driver transaction in its own small function, and write everything around it as Prisma ORM queries.
Coming from Prisma ORM 7
Prisma ORM 8 has no $transaction, so both of the Prisma ORM 7 forms become db.transaction(async (tx) => ...). The interactive form maps one for one: rename the method and query through tx.
- const result = await prisma.$transaction(async (prisma) => {
- const user = await prisma.user.create({ data: { email, name } });
- return prisma.post.create({ data: { title, authorId: user.id } });
- });
+ const result = await db.transaction(async (tx) => {
+ const user = await tx.orm.public.User.create({ email, name });
+ // published is required and has no default, so you pass it
+ return tx.orm.public.Post.create({ title, published: false, authorId: user.id });
+ });The array form has no replacement of its own, so write the queries out in the callback, one after another:
- const [user, post] = await prisma.$transaction([
- prisma.user.create({ data: { email, name } }),
- prisma.post.create({ data: { title, authorId } }),
- ]);
+ const { user, post } = await db.transaction(async (tx) => {
+ const user = await tx.orm.public.User.create({ email, name });
+ const post = await tx.orm.public.Post.create({ title, published: false, authorId: user.id });
+ return { user, post };
+ });You get the same atomicity, and you also get what the array form never allowed: one query's result (here user.id) can feed the next query in the same transaction.
Common mistakes
Side effects inside the callback
Sending an email or queueing a job inside the callback looks natural, because it is right next to the write it belongs to:
await db.transaction(async (tx) => {
const user = await tx.orm.public.User.create({ email, name });
await sendWelcomeEmail(user.email); // runs even if the transaction rolls back
});Database writes roll back; emails don't. If a later statement throws, the record disappears but the email was already sent. Return what you need from the callback, and run the side effect after the transaction has committed:
const user = await db.transaction(async (tx) => {
return tx.orm.public.User.create({ email, name });
});
await sendWelcomeEmail(user.email);Now the email can only go out for a user that actually exists.
Querying through db instead of tx
If you open a transaction but keep writing db.orm inside the callback:
await db.transaction(async (tx) => {
await db.orm.public.User.create({ email, name }); // outside the transaction
});Queries on db run on their own connection, outside the open transaction, so they commit immediately and won't roll back with the rest of the callback. Use tx for every query inside the callback: tx.orm for models, tx.sql and tx.execute for the SQL query builder.
Prompt your coding agent
Projects created with npm create prisma@latest include the Prisma ORM skills, instruction files for coding agents, and in an existing project you run npx prisma skills sync to add them. The prisma-8 skill covers transactions, so try prompts that map to each section:
- "Using the prisma-8 skill, wrap this signup flow (create user, create welcome post) in a db.transaction so both writes commit together."
- "Check this transaction callback for queries that use db instead of tx, and move the email send after the commit."
- "Refactor these two service functions so the helper takes tx as a parameter instead of opening its own transaction."
- "This project is on MongoDB. Show me the driver-session pattern for an atomic two-collection write with a shared MongoClient."
Next
- Write data: the single-record and bulk mutations you group in a transaction.
- Use advanced queries to run SQL query builder statements inside or outside transactions.
