Array Mode
Batch multiple queries into a single atomic transaction with typed tuple results.
Pass an array of query promises to $transaction. Every query runs inside a single transaction, and the results come back as a typed tuple matching each position.
Basic Usage
Wrap any number of model method calls in an array. They execute atomically: all succeed, or all roll back.
const [user, post] = await client.$transaction([
client.db.User.create({ data: { name: 'Alice', email: 'alice@example.com', isActive: true } }),
client.db.Post.create({ data: { title: 'Hello World', authorId: existingUserId } }),
]);
// user: User, post: Post — both created atomicallyTyped Tuple Results
Each position in the result array matches the return type of its corresponding query. You get full type safety through destructuring:
const [created, users, count, exists] = await client.$transaction([
client.db.User.create({ data: { email: 'new@example.com', name: 'New', isActive: true } }),
client.db.User.findMany(),
client.db.Post.count(),
client.db.Tag.exists({ name: 'typescript' }),
]);
// created: User
// users: User[]
// count: number
// exists: booleanUp to 6 items produce precise arity-typed tuples. With 7 or more items, the result type widens to any[].
Function Items
Array items can also be functions. They receive all previous results, letting later operations depend on earlier ones.
Accessing Previous Results
Functions receive a prevResults array containing the results of every item before them. Previous results are Cerial-mapped, meaning CerialId fields, Date objects, and other wrapper types are fully hydrated.
const [user, post] = await client.$transaction([
client.db.User.create({ data: { name: 'Alice', email: 'alice@example.com', isActive: true } }),
(prevResults) => {
const createdUser = prevResults[0] as User;
return client.db.Post.create({
data: { title: 'First Post', authorId: createdUser.id },
});
},
]);Return Types
Functions can return several types of values:
- A
CerialQueryPromise(any model method call), which gets executed inside the transaction - A plain value (string, number, object), passed through as-is into the results
void/undefined, for side-effect-only functions- An async value (async functions are fully supported)
const results = await client.$transaction([
client.db.User.create({ data: { email: 'a@b.c', name: 'Alice', isActive: true } }),
// Return a query — executed in the transaction
(prev) => client.db.Post.create({ data: { title: 'Post', authorId: prev[0].id } }),
// Return a plain value
(prev) => prev[0].name,
// Side effect only (void)
() => { console.log('Transaction in progress'); },
// Async function
async (prev) => {
await someExternalCheck();
return client.db.Tag.create({ data: { name: 'verified' } });
},
]);Result Accumulation
Each function receives ALL previous results, including values returned by earlier functions:
const results = await client.$transaction([
client.db.User.create({ data: { email: 'a@b.c', name: 'Alice', isActive: true } }),
(_prev) => 42,
(prev) => {
// prev[0] = User, prev[1] = 42
const num = prev[1] as number;
return num * 2;
},
]);
// results[0]: User
// results[1]: 42
// results[2]: 84Function at Position 0
A function as the first item receives an empty array:
const [user] = await client.$transaction([
(prev) => {
// prev is []
return client.db.User.create({ data: { email: 'a@b.c', name: 'First', isActive: true } });
},
]);Mixing Queries and Functions
You can freely interleave CerialQueryPromise items and function items in any order:
const [user, post, otherUser, totalItems] = await client.$transaction([
client.db.User.create({ data: { email: 'a@b.c', name: 'Alice', isActive: true } }),
(prev) => client.db.Post.create({ data: { title: 'Post', authorId: prev[0].id } }),
client.db.User.create({ data: { email: 'b@c.d', name: 'Bob', isActive: false } }),
(prev) => prev.length, // 3
]);Error Handling in Functions
Throwing inside a function rolls back the entire transaction. Nothing before the throw persists:
try {
await client.$transaction([
client.db.User.create({ data: { email: 'a@b.c', name: 'Alice', isActive: true } }),
() => {
throw new Error('Validation failed');
},
]);
} catch (error) {
// Alice was NOT created — entire transaction rolled back
}Supported Operations
All model methods work inside array-mode transactions:
| Method | Result Type | Example |
|---|---|---|
findOne | T | null | db.User.findOne({ where: { id } }) |
findMany | T[] | db.User.findMany({ where: { isActive: true } }) |
findUnique | T | null | db.User.findUnique({ where: { email } }) |
create | T | db.User.create({ data: { ... } }) |
updateMany | T[] | db.User.updateMany({ where: { ... }, data: { ... } }) |
updateUnique | T | null | db.User.updateUnique({ where: { id }, data: { ... } }) |
deleteMany | number | db.User.deleteMany({ where: { ... } }) |
deleteUnique | boolean | db.User.deleteUnique({ where: { id } }) |
upsert | T | null | db.User.upsert({ where: { ... }, create: { ... } }) |
count | number | db.User.count({ isActive: true }) |
exists | boolean | db.User.exists({ role: 'admin' }) |
Cross-Model Transactions
Transactions can span any number of different models. Each query targets its own table, but they all share the same atomic guarantee.
// Create across three models atomically
const [user, profile, tag] = await client.$transaction([
client.db.User.create({ data: { email: 'a@b.c', name: 'Alice', isActive: true } }),
client.db.Profile.create({ data: { bio: 'Hello world' } }),
client.db.Tag.create({ data: { name: 'welcome' } }),
]);Mix different operation types across models in the same transaction:
const [updatedUser, deleteResult] = await client.$transaction([
client.db.User.updateUnique({
where: { id: userId },
data: { name: 'Updated Name' },
}),
client.db.Post.deleteUnique({ where: { id: postId } }),
]);
// updatedUser: User | null
// deleteResult: booleanNested Operations
Nested create, connect, and disconnect all work inside transactions. The parent operation and all its nested children share the same atomic boundary.
Nested Create
const [user] = await client.$transaction([
client.db.User.create({
data: {
name: 'Charlie',
email: 'charlie@example.com',
isActive: true,
posts: { create: [{ title: 'Post 1' }, { title: 'Post 2' }] },
},
}),
]);
// User and both posts created atomicallyConnect
const [student] = await client.$transaction([
client.db.Student.create({
data: {
name: 'Alice',
email: 'alice@school.edu',
courses: { connect: [mathCourseId, scienceCourseId] },
},
}),
]);
// Student created with courses connected — bidirectional sync includedDisconnect
const [updated] = await client.$transaction([
client.db.Student.updateMany({
where: { id: studentId },
data: {
courses: { disconnect: [oldCourseId] },
},
}),
]);Select and Include
select and include work normally inside transactions. Return types narrow accordingly.
// Select specific fields
const [user] = await client.$transaction([
client.db.User.create({
data: { email: 'a@b.c', name: 'Alice', isActive: true },
select: { id: true, name: true },
}),
]);
// user: { id: CerialId; name: string } — only selected fields returned// Include relations
const [userWithPosts] = await client.$transaction([
client.db.User.findOne({
where: { id: userId },
select: { id: true, name: true },
include: { posts: true },
}),
]);
// userWithPosts: ({ id: CerialId; name: string } & { posts: Post[] }) | nullCascade Delete
Cascade delete behavior is preserved inside transactions. If a model has @onDelete(Cascade) on a relation, deleting the parent also removes the related record, all within the same atomic boundary.
// If ProfileCascade has @onDelete(Cascade) on its user relation,
// deleting the user also deletes the profile — atomically
const [deleted] = await client.$transaction([
client.db.UserCascade.deleteUnique({ where: { id: userId } }),
]);
// Both user AND their cascade-linked profile are removedYou can combine cascade deletes with other operations in the same transaction:
const [deleteResult, newUser] = await client.$transaction([
client.db.UserCascade.deleteUnique({ where: { id: oldUserId } }),
client.db.UserCascade.create({ data: { name: 'Replacement' } }),
]);
// Old user + their cascaded profile gone, new user created — all atomicAtomicity
Every array-mode transaction is all-or-nothing. If any query fails, every change rolls back.
Rollback on Duplicate
try {
await client.$transaction([
client.db.User.create({ data: { name: 'Alice', email: 'alice@example.com', isActive: true } }),
client.db.User.create({ data: { name: 'Bob', email: 'alice@example.com', isActive: true } }), // duplicate email
]);
} catch (error) {
// Neither user was created — full rollback
}Rollback Affects All Operations
A failure anywhere undoes preceding operations too, including updates:
const user = await client.db.User.create({
data: { email: 'orig@example.com', name: 'Original', isActive: true },
});
try {
await client.$transaction([
client.db.User.updateMany({
where: { id: user.id },
data: { name: 'Updated' },
}),
client.db.User.create({
data: { email: 'orig@example.com', name: 'Duplicate', isActive: false }, // fails
}),
]);
} catch {
// The update was ALSO rolled back — user.name is still 'Original'
}Edge Cases
Empty transactions return an empty array: await client.$transaction([]) produces [].
Result types are preserved across all positions. CerialId fields, Date objects, null for missing records, number for count/deleteMany, and boolean for exists/deleteUnique all come back correctly mapped.
Large transactions work fine. 10+ operations in a single array are supported with no special handling required.
Concurrent transactions succeed independently. Multiple $transaction calls via Promise.all each run as separate transactions. They don't interfere with each other.