Transactions
Prisma Intermediate/Advanced
Chapter 1 ยท Transactions
This course picks up where Prisma Fundamentals left off, using its capstone blog API as the starting project. The first topic is the one that separates a working demo from a trustworthy application: transactions, which make several database changes succeed or fail together, and concurrency control, which stops two people's changes from silently overwriting each other.
Why Transactions Matter
In Prisma Fundamentals, Chapter 7, deleting a user who had posts meant two steps: reassign or delete their posts, then delete the user. Run them as two separate queries, and a crash or error between them leaves the data half-changed.
A transaction groups operations so the database treats them as one unit. Either every change is saved (committed), or none is (rolled back). Other queries never see a half-finished state.
| Property | Meaning |
|---|---|
| Atomic | All the changes happen, or none do |
| Consistent | The database's rules (unique values, foreign keys) hold before and after |
| Isolated | Transactions running at the same time don't see each other's unfinished work |
| Durable | Once committed, changes survive a crash |
Together these are known as ACID. Prisma gives you three ways to use transactions.
1. Nested Writes (Already Transactions)
Every nested write from Prisma Fundamentals, Chapter 8 already runs as a transaction. Creating a user with a
profile and posts in one create call either saves all of them or none. When the operations are about
one record and its relations, a nested write is the simplest choice.
2. Batch Transactions: $transaction([...])
You pass a list of queries, without await on each one. Prisma runs them in order inside one
transaction and returns their results in the same order. If any fails, all are rolled back.
await prisma.post.updateMany(...) inside the array runs that query immediately, on its own,
before the transaction starts. Build the queries without await, and await only
$transaction.
The limitation: every query is decided in advance. You can't look at the result of the first query and decide what the second should do.
3. Interactive Transactions: $transaction(async (tx) => ...)
When later steps depend on earlier results, pass a function instead of a list:
- Inside the function, use
tx, notprisma. Only queries made throughtxare part of the transaction. - If the function throws, everything is rolled back. If it returns, everything is committed, and
$transactionreturns the value.
prisma.post.update(...) inside the function runs outside the transaction. It
commits on its own and isn't rolled back if the transaction fails later. TypeScript won't warn you, so check
every line.
Timeouts: keep transactions short
An open transaction holds locks that can block other requests. Prisma cancels and rolls back a transaction that
runs past its timeout. Never do slow work inside one — calling an external API, sending an email,
waiting for a user. Gather what you need first, keep the transaction to database work, and do the rest after it
commits.
Isolation Levels
Isolation controls how much concurrent transactions can see of each other. Stricter levels prevent more anomalies but allow less work to happen in parallel.
| Database | Levels Prisma supports |
|---|---|
| PostgreSQL, MySQL | ReadUncommitted, ReadCommitted, RepeatableRead, Serializable |
| SQL Server | All of those, plus Snapshot |
| SQLite, CockroachDB | Serializable only |
With stricter levels, the database may abort a transaction that conflicts with another. Prisma reports this as
error P2034, and the right response is simply to try the transaction again:
Optimistic Concurrency: Stopping Lost Updates
Two editors open the same post. Ada fixes a typo and saves; a minute later Grace, still looking at the old version, rewrites a paragraph and saves. Grace's save silently wipes out Ada's fix. A transaction doesn't help here, because the two saves happen minutes apart.
The standard fix is a version number. Each save says "update this post only if it's still the version I loaded," and increases the version:
Ada loads version 3 and saves: the post becomes version 4. Grace, who also loaded version 3, then tries to save
— but no post matches version: 3 any more, so nothing is changed and she's told to reload. No
locks are held while people are editing; conflicts are simply detected when they happen. That's why it's called
optimistic.
Choosing the Right Tool
| Situation | Use |
|---|---|
| Creating or updating a record with its related records | A nested write |
| Several independent changes that must succeed together | $transaction([...]) |
| Later steps depend on earlier results | An interactive transaction |
| A single counter update | { increment: 1 } — no transaction needed |
| People editing the same data over minutes | A version field (optimistic concurrency) |
Hands-On Exercises
Using a batch transaction, publish every draft post by one author and set their role to AUTHOR, all or nothing. Then deliberately make the second query fail and prove the first was rolled back.
Write an interactive transaction mergeTags(fromName, intoName) that moves every post from one tag to another and then deletes the old tag. It should throw, leaving everything unchanged, if either tag doesn't exist.
Add a version field to Post and a PATCH /posts/:slug route to the Fundamentals capstone API that uses it. Simulate two editors saving from the same loaded version and show that the second gets a 409.
Chapter 1 Quick Reference
- A transaction makes several changes commit or roll back together (ACID)
- Nested writes are already transactions
$transaction([q1, q2])runs pre-built queries in order, atomically — don'tawaitthem individually$transaction(async (tx) => ...)for logic between steps; usetxfor every query; throw to roll back- Options:
maxWait(default 2000 ms),timeout(default 5000 ms),isolationLevel; keep transactions short - SQLite and CockroachDB support only
Serializable; retry onP2034conflicts - Optimistic concurrency: a
versionfield checked inwhereand incremented on save