Choosing Between Prisma Interactive Transactions and Batch Operations for Atomic Writes
A decision guide for choosing between Prisma interactive transactions and batch operations (createMany, updateMany, deleteMany) based on atomicity scope, isolation needs, throughput, and middleware requirements. Includes a concrete seeding example and validation checklist.
25 Dec 2025, 22:03 UTC

The Decision: Interactive Transactions vs. Batch Operations
When you need to write multiple records atomically in Prisma, you have two fundamentally different tools: $transaction(callback) (interactive transactions) and bulk methods like createMany, updateMany, deleteMany. The choice affects latency, lock contention, isolation guarantees, and whether you can mix reads with writes. This guide lays out the constraints, compares the options, and shows how to validate the right pick for your workload.
Constraints That Drive the Choice
- Atomicity scope – Do you need a single all-or-nothing unit across heterogeneous models (e.g., parent + children) or just a homogeneous batch of one model?
- Read-write interleaving – Must you read data, compute, then write based on that read inside the same atomic block?
- Isolation level – Do you require RepeatableRead or Serializable guarantees (PostgreSQL, SQL Server, CockroachDB)?
- Throughput vs. latency – Are you inserting 10k rows in a background job (throughput) or handling a user-facing request where lock time must stay under 50 ms?
- Middleware and defaults – Do you rely on Prisma middleware,
@updatedAt, or JS-side defaults likecuid()?
Quick Comparison
| Aspect | Interactive Transaction ($transaction(fn)) | Batch Operations (createMany, updateMany, deleteMany) |
|---|---|---|
| Atomicity | Across any number of models and operations | Single model, single statement |
| Read/write interleaving | Supported – arbitrary JS logic between reads/writes | Not supported – write-only |
| Isolation level control | Yes (ReadUncommitted…Serializable) on PG, SQL Server, CockroachDB | Database default only |
| Error handling | Auto rollback on thrown error | Statement fails entirely; PrismaClientKnownRequestError (e.g., P2003) |
| Typical overhead | ~1–5 ms + lock hold time | Single round-trip, no callback overhead |
| Throughput (10k rows) | Seconds | 10–100× faster |
| Connection pool pressure | Holds a connection for callback duration | Releases immediately |
| Middleware / @updatedAt / JS defaults | Executed | Skipped |
| Return created records | Yes (via callback return) | Prisma 5.10+: createManyAndReturn / updateManyAndReturn (PG, SQLite, SQL Server) |
| Nesting | Not allowed (throws) | N/A |
Trade‑off Deep Dive
Lock Contention and Concurrency
Interactive transactions hold row/table locks for the entire callback. If your callback does an external API call or heavy computation, locks are held for hundreds of milliseconds, blocking other writers and exhausting the connection pool. Batch operations acquire locks only for the single SQL statement, minimizing contention. For high‑concurrency write paths, keep interactive callbacks under 50 ms or switch to batch.
Isolation Level Requirements
If you need RepeatableRead or Serializable (e.g., to prevent phantom reads in a financial ledger), you must use an interactive transaction with the isolationLevel option. Batch operations run at the database’s default isolation level (usually ReadCommitted) and cannot be customized per call. MySQL does not support the isolationLevel option at all; it uses the session transaction isolation level.
Middleware, Defaults, and Timestamps
Batch operations bypass Prisma middleware, so any logic in $extends or prisma.$use won’t run. They also skip @updatedAt auto‑update on related records and do not evaluate JS‑side defaults like @default(cuid()) or @default(uuid()). If your schema relies on those, use interactive transactions or compute the values in application code before calling createMany.
Partial Failure and Unique Constraints
createMany with skipDuplicates: true (PostgreSQL/SQLite) silently ignores unique‑constraint violations but still consumes auto‑increment values, leaving gaps. MySQL and SQL Server do not support skipDuplicates. Interactive transactions let you catch the error, inspect the record, and decide whether to retry or compensate.
Concrete Implementation: Seeding 10,000 Related Records
Suppose you need to create 10,000 Order rows, each with 3 OrderItem children, atomically. Two approaches:
Approach A – Interactive Transaction (heterogeneous, read‑write)
// Run in a Node.js script with Prisma Client (Prisma ≥5.7)
import { PrismaClient, Prisma } from '@prisma/client'
const prisma = new PrismaClient()
async function seedWithTransaction() {
const start = Date.now()
await prisma.$transaction(async (tx) => {
for (let i = 0; i < 10000; i++) {
const order = await tx.order.create({
data: { customerId: 1, status: 'PENDING' },
})
await tx.orderItem.createMany({
data: [
{ orderId: order.id, productId: 1, qty: 1 },
{ orderId: order.id, productId: 2, qty: 2 },
{ orderId: order.id, productId: 3, qty: 1 },
],
})
}
}, { timeout: 30000 }) // increase from default 5s
console.log(`Transaction took ${Date.now() - start}ms`)
}
Where to run: Any Node.js environment with a connected PostgreSQL database. Requires DATABASE_URL env var and prisma generate done. Permissions: INSERT on Order and OrderItem tables.
Expected checks: Verify all 10,000 orders and 30,000 items exist via prisma.order.count(). Monitor pg_stat_activity for long‑running transactions. Risk: callback exceeds timeout → transaction abort, no partial writes.
Approach B – Batch Operations (homogeneous, maximum throughput)
async function seedWithBatch() {
const start = Date.now()
// 1. Create all orders in one statement
const ordersData = Array.from({ length: 10000 }, (_, i) => ({
customerId: 1,
status: 'PENDING',
}))
const createdOrders = await prisma.order.createManyAndReturn({
data: ordersData,
select: { id: true }, // Prisma 5.10+ on PostgreSQL
})
// 2. Build items for all orders
const itemsData = createdOrders.flatMap((order) => [
{ orderId: order.id, productId: 1, qty: 1 },
{ orderId: order.id, productId: 2, qty: 2 },
{ orderId: order.id, productId: 3, qty: 1 },
])
await prisma.orderItem.createMany({ data: itemsData })
console.log(`Batch took ${Date.now() - start}ms`)
}
Where to run: Same environment, but requires Prisma ≥5.10 and PostgreSQL (or SQLite/SQL Server) for createManyAndReturn. Permissions: same as above.
Expected checks: Count orders and items. Verify createdOrders.length === 10000. Risk: if the second createMany fails, orders remain without items – no atomicity across models. Use interactive transaction if cross‑model atomicity is mandatory.
Validation Checklist
- Measure latency – Run both scripts against a staging PostgreSQL instance. Capture
Date.now()deltas andpg_stat_activitylock durations. - Test isolation – Open two
psqlsessions. In session 1:BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT * FROM orders WHERE id=1;. In session 2:BEGIN; UPDATE orders SET status='SHIPPED' WHERE id=1; COMMIT;. Session 1 commits → should see serialization failure (SQLSTATE 40001). Confirms interactive transaction isolation works. - Verify rollback – Throw inside
$transactioncallback and confirm zero rows inserted. Compare withcreateManythrowing on duplicate unique key – entire batch rolls back. - Check version – Run
npx prisma --version. Ensure ≥5.10 forcreateManyAndReturn. Review release notes for transaction API changes. - Monitor pool – Under load (e.g.,
wrkork6), watchpg_stat_activityfor idle‑in‑transaction connections. Interactive transactions should not exceed pool size.
Limitations and Gotchas
- Interactive transactions cannot be nested. If you need multiple logical units, combine them in one callback.
- Default transaction timeout is 5 seconds (includes JS execution). Increase via
timeoutoption or move side effects outside. - Batch operations on related models require separate calls and cannot guarantee cross‑model atomicity.
skipDuplicatesis PostgreSQL/SQLite only and creates auto‑increment gaps.- MySQL does not support
isolationLevelin$transaction.
Decision Flow
- Do you need atomicity across multiple models or read‑write logic? → Interactive transaction.
- Is the workload a homogeneous bulk write (≥1k rows) with no reads? → Batch operations (
createMany/updateMany/deleteMany). - Do you need custom isolation level? → Interactive transaction (PG/SQL Server/CockroachDB).
- Do you rely on middleware,
@updatedAt, or JS defaults? → Interactive transaction or pre‑compute values. - Is lock contention a concern (high concurrency, user‑facing)? → Batch operations or keep interactive callback <50 ms.
Practical Verification
After implementing your chosen approach, run the validation checklist in a staging environment that mirrors production hardware and data volume. Capture metrics (latency, lock wait time, error rates) and compare against your SLAs. If interactive transactions exceed 50 ms median latency, refactor to batch or split the workload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.