mport a batch of records — a CSV upload, a bulk create, anything that writes several rows in one logical operation — and eventually one of them will fail partway through: a duplicate, a constraint violation, a bad value in row 4 of 200. The question that matters isn't "how do we handle the error," it's "what state is the database left in the moment it happens." Without a transaction, the honest answer is: however far it got. Three successful writes, a failure, and 196 rows that never ran — a batch operation that partially happened.
prisma.$transaction()turnsalistofwritesintooneatomicunit—eithereveryoneofthemcommits,ornoneofthemdo.Thereisnopartialstatetocleanupafter.
See both outcomes
wrapped in a transaction
Run it unwrapped and record 4 fails while 1 through 3 sit there committed and 5 never runs — real, permanent rows in the database that now need their own manual cleanup, because the failure happened, but so did the successes before it. Run it wrapped and record 4 still fails, but everything else rolls back with it — the database ends the operation in exactly the state it started in, as if the batch had never been attempted.
typescript
// Real pattern, from a CSV import route on this site: const created = await prisma.$transaction( deduped.map((data) => prisma.record.create({ data })) ); // If ANY create() in that array throws, none of them persist. // The route's catch block runs against a database unchanged // by the attempt — no partial import to reconcile by hand.
Why this matters more for imports than single writes
A single failed write is usually easy to reason about — it either happened or it didn't, and the user sees an error either way. A batch is where partial failure gets genuinely dangerous, because the operation looks like it happened (three rows, five rows, whatever got through) without actually completing the thing the user asked for. "Import my 200 leads" that silently becomes "import my first 47 leads" is a worse failure mode than an outright rejected import, because nobody notices the missing 153 until something downstream depends on them and comes up short.
The tradeoff worth naming
Atomicity isn't free — a transaction holds its writes uncommitted until every operation in it succeeds, which means longer-held locks and, for a genuinely huge batch, a meaningfully bigger unit of work to roll back if something late in the list fails. For an import sized in the hundreds or low thousands of rows, that cost is trivial next to the alternative of a half-imported dataset nobody's certain about. For something in the millions of rows, chunking into smaller transactional batches is usually the better trade — all-or-nothing, just at a smaller granularity than "the entire file."