Prisma and ORM pitfalls: transactions, raw SQL, over-fetching, connections
An ORM call is SQL on a borrowed connection: see the SQL, select what you need, and keep slow work outside the transaction.
Predict first
Before you read: predict
ExamHall's API runs Prisma 7 with a pool of 3 connections. Final submit:
The grading vendor stalls. Three candidates' submits are waiting on notify. A fourth candidate presses Submit.
What happens to the fourth submit?
async function submit(id) { await prisma.$transaction(async (tx) => { await tx.submission.update({ where: { id }, data: { status: 'submitted' } }); await gradingVendor.notify(id); }, { timeout: 30_000 });}The 30-second version
Every Prisma call is SQL: log it, and EXPLAIN what matters. Without select, Prisma reads every column. An interactive $transaction holds a pooled connection from before its first line to its end, so an awaited HTTP call inside it holds that connection and its locks. Prisma bounds both ends: maxWait to start, timeout to finish. One PrismaClient, one pool.
An ORM call is SQL on a borrowed connection: see the SQL, select what you need, and keep slow work outside the transaction.
How it works
Step 1 of 13
log: [{ emit: 'event', level: 'query' }] with prisma.$on('query', …) hands you each statement and its parameters.In the kitchen
- asking at the desk returns a slip saying exactly which court and slot was booked → the query log shows each statement (step 1)
Analogy: A squash club's courts (racket sports)
The club's front desk books courts for members; members never see the court diary.
| At the squash club | In Prisma |
|---|---|
| asking at the desk returns a slip saying exactly which court and slot was booked | the query log shows each statement (step 1) |
| asking "who is on court 2?" and getting each player's whole membership file | no select reads every column (step 2) |
| a match booking: the key is yours from the moment you take it until you hand it back, even while you step out to phone someone | the interactive callback holds its connection from before its first line (step 5) |
| a player waiting at the desk for a match court is sent away after two minutes, while someone dropping off a form just queues | maxWait on a transaction's start; a plain query only queues (step 7) |
| at the end of the booked hour the attendant clears the court and wipes the scoreboard, but the phone call you made is still made | timeout rolls back; work outside the database stays done (steps 8–9) |
| each new branch of the club builds its own courts | each PrismaClient opens its own pool (step 11) |
In real terms: take the connection only for the statements, and do the phoning outside.
Breaks down when: a court is booked for a fixed hour chosen in advance; a transaction lasts exactly as long as the code takes, and the desk cannot see that the player inside has stopped playing.
Examples, run for real
4.1 ExamHall EXH-3850 → MR !4150: final submit and the slow grading vendor
ExamHall's final submit called gradingVendor.notify() inside prisma.$transaction. At 10:00 the vendor slowed down, and all 20 pooled connections sat in transactions waiting on it. MR !4150 commits the status and a pending-notification row with an array $transaction([...]); a worker sends the notifications from that table:
4.1 shipped (notify inside the callback), pool max 20: 20 submits waiting on notify · connections out 20 of 20 · submit 21: P2028 Unable to start a transaction in the given time. · dashboard findMany: still queued in the pool when submit 21 failed, answered (10 rows) after the vendor recovered
4.1 MR !4150 (array $transaction, then a worker calls notify): 21 of 21 submits committed while the vendor stalls · connections out during the stall 0 of 20 · pending notification rows 21 (counted on every connection's database)
With the call inside the callback, every connection waits on the vendor, so the next submit cannot start its transaction and a plain dashboard query queues behind them (steps 5, 7). After MR !4150 the same stall holds no connection: submits commit at once and the notifications wait in a table (steps 4, 10).
4.2 Ticketing: the seat map, as Prisma sends it
The box office logs the statements its seat-map query sends, before and after adding select:
4.2 findMany, no select: SELECT id, event_id, label, status, held_by FROM seats WHERE (event_id = $1 AND status = $2) OFFSET $3 · params ["ev-1","free","0"] · 5 columns
4.2 findMany, select { id, label }: SELECT id, label FROM seats WHERE (event_id = $1 AND status = $2) OFFSET $3 · 2 columns
The first statement reads every column of seats although the page shows two; select names the two (steps 1–2). One findMany is one statement here; its plan is read the same way (step 3).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
ExamHall's final submit sets a 2-second transaction timeout:
At 10:00 the vendor is slow: it answers notify OK after 9 seconds.
Afterwards, what is true?
async function submit(id) { await prisma.$transaction(async (tx) => { await tx.submission.update({ where: { id }, data: { status: 'submitted' } }); await gradingVendor.notify(id); }, { timeout: 2_000 });}Question 2 of 7
A box office's API runs Prisma 7 with a pool of 2 connections. An MR moves the payment call to the top of the callback, "so no seat is locked while the provider answers":
The provider stalls with two buyers' holds waiting on it. A third buyer taps a seat.
What happens to the third hold?
async function hold(seatId, buyer) { await prisma.$transaction(async (tx) => { await psp.createPaymentIntent(buyer); await tx.seat.update({ where: { id: seatId }, data: { status: 'held', heldBy: buyer } }); }, { timeout: 30_000 });}Question 3 of 7
Proctors search for candidates whose email contains the text they type. candidates holds 5,000 rows and has a unique index on email:
With query logging on, the team copies the statement Prisma sent and runs EXPLAIN on it with its parameter.
What do they see?
const found = await prisma.candidate.findMany({ where: { email: { contains: typed } },});Question 4 of 7
The proctor dashboard shows each submission's status for exam E2:
submissions has seven columns, answers and feedback among them, each 1,000 characters or more.
Which columns does the statement Prisma sends read?
const rows = await prisma.submission.findMany({ where: { examId: 'E2' } });return rows.map((s) => ({ id: s.id, status: s.status }));Question 5 of 7
A box office's seat-map route, in one Node process:
The route serves 5 requests, one after another.
How many connections does the database accept?
export async function GET() { const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }); const free = await prisma.seat.findMany({ where: { eventId: 'ev-1', status: 'free' } }); const held = await prisma.seat.count({ where: { eventId: 'ev-1', status: 'held' } }); return Response.json({ free, held });}Question 6 of 7
A box office's refund must mark the order refunded and write a ledger row together, then call the payment provider's refund.
Which version holds no connection while psp.refund is awaited, and leaves the order unchanged if the ledger insert fails?
Question 7 of 7
A box office upgrades from Prisma 6 to Prisma 7.10. DATABASE_URL is set as before, and its client module still reads:
What happens when the module loads and the first seat query runs?
export const prisma = new PrismaClient();Go deeper
Pro tip
- In an integration test, wrap the call under test with the query log on and fail when a statement reads a column the endpoint never returns.
When: a list endpoint over a table with large text or JSON columns (steps 1–2). Status: SELF (Prisma's logging page shows theconst seen = []; prisma.$on('query', (e) => seen.push(e.query)); await getDashboard('E2'); expect(seen.join('\n')).not.toMatch(/"answers"/);queryevent; not run as a graded item).
Source
quote-check.mjs checks every quotation below verbatim in its saved page. Every key in this lesson is executed; these passages support the teaching and name the defaults the scripts confirm. Valid as of 2026-10-13: the Prisma docs now default to Prisma ORM 8 and keep the ORM 7 pages under /orm/v7; this lesson is written for Prisma ORM 7 (the row's version) and runs 7.10.0.
- Prisma ORM 7, Transactions and batch queries (the row's first source), https://www.prisma.io/docs/orm/v7/prisma-client/queries/transactions (
prisma-transactions.txt): "Pass an array of queries to execute sequentially in a transaction" (step 4); "For complex logic between queries, use interactive transactions" (step 4); "maxWait: 5000, // Max wait to acquire transaction (default: 2000ms)" (step 7); "timeout: 10000, // Max transaction run time (default: 5000ms)" (step 8); "Try to avoid performing network requests and executing slow queries inside your transaction functions." (step 10). - Prisma ORM 7, Upgrade to Prisma ORM 7 (the row's second source), https://www.prisma.io/docs/guides/upgrade-prisma-orm/v7 (
prisma-upgrade-v7.txt): "The way to create a new Prisma Client has changed to require a driver adapter for all databases." (step 12); "Driver adapters use the connection pool settings from the underlying Node.js database driver, which may differ significantly from Prisma ORM v6 defaults." (step 12). - Prisma ORM 7, Query optimization (the row's third source), https://www.prisma.io/docs/orm/v7/prisma-client/queries/advanced/query-optimization-performance (
prisma-query-optimization.txt): "Common causes of slow queries:" followed by "Over-fetching data" and "Missing indexes" (steps 2–3). - Prisma ORM 7, Select fields (teaching only; the row does not name it, so it keys nothing), https://www.prisma.io/docs/orm/v7/prisma-client/queries/select-fields (
prisma-select-fields.txt): "If you do not pass select, include, or omit, Prisma Client returns all scalar fields for the model and excludes relations from the result." (step 2). - Prisma ORM 7, Logging (teaching only), https://www.prisma.io/docs/orm/v7/prisma-client/observability-and-logging/logging (
prisma-logging.txt): "Set emit to event for a specific log level, such as query" (step 1). - Prisma ORM 7, Database connections (teaching only), https://www.prisma.io/docs/orm/v7/prisma-client/setup-and-configuration/databases-connections (
prisma-databases-connections.txt): "Create one instance of PrismaClient and re-use it across your application" and "each client instance has its own connection pool" (step 11). - Prisma ORM 7, Connection pool (teaching only), https://www.prisma.io/docs/orm/v7/prisma-client/setup-and-configuration/databases-connections/connection-pool (
prisma-connection-pool.txt): "Driver adapters rely on the Node.js driver you supply, so connection pooling defaults (and configuration) now come from the driver itself." (step 12). - PostgreSQL 17, Explicit Locking (teaching only; the row lock step 5 cites, not executed), https://www.postgresql.org/docs/17/explicit-locking.html (
pg17-explicit-locking.txt): "Row-level locks are released at transaction end or during savepoint rollback, just like table-level locks." (step 5). - PostgreSQL 18, Explicit Locking (teaching only; the major version PGlite runs), https://www.postgresql.org/docs/18/explicit-locking.html (
pg18-explicit-locking.txt): "Once acquired, a lock is normally held until the end of the transaction." (step 5). - Prisma ORM 6, Case sensitivity (teaching only; the ORM 7 path answers 404 and redirects here), https://www.prisma.io/docs/orm/v6/prisma-client/queries/case-sensitivity (
prisma-case-sensitivity.txt): "To support case-insensitive filtering, use the mode: 'insensitive' property on a per-field basis.".
Related lessons
Checked against the tools and docs as of 2026-10-13.