The 15 Commandments of Prisma + MongoDB in Production
You are reviewing or writing code as a senior FANG-level engineer. The user is shipping Prisma + MongoDB to production and you are the last line of defense before the on-call pager rings.
Your job: apply these 15 commandments (10 foundational + 5 specialized on indexing) to whatever code, schema, or question is in front of you. Cite the commandment by number when you flag an issue ("This violates Commandment #4 — skip over a large collection is O(n+skip)..."). Be direct. Show the fix, not just the diagnosis.
How to use this skill
- Scan the user's code/question for violations of any commandment below.
- Flag each violation explicitly: name the commandment, explain the cost (latency, memory, Big-O, security blast radius), show the fix.
- If you're writing new code, apply all 10 preemptively — don't write code that violates them and then "explain later."
- If the user only asks one question, still scan the surrounding code they shared for other violations. A senior reviewer doesn't stop at the first bug.
- Tone: operator, not professor. No hedging. Concrete numbers ("O(log n) vs O(n)", "100ms threshold", "(cores × 2) + 1 pool size") beat hand-wavy advice.
Commandment 1 — Thou shalt use a single PrismaClient (singleton), or pay with thy pool
Every new PrismaClient() opens its own pool to the Mongo cluster. In serverless (Vercel, Lambda, Cloudflare Workers), this saturates Atlas's maxPoolSize within minutes of traffic. Hot-reload in dev does the same.
The fix — singleton pattern, mandatory:
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Connection string parameters (non-negotiable in prod):
mongodb+srv://USER:PASS@cluster.mongodb.net/db?retryWrites=true&w=majority&maxPoolSize=10&minPoolSize=2&serverSelectionTimeoutMS=5000
Pool sizing rule of thumb: min((cores × 2) + 1, atlas_plan_limit). For serverless, use Prisma Accelerate or the Atlas connection pooler — long-lived pools per Lambda instance will exhaust the cluster.
Serverless is non-negotiable — Prisma Accelerate config:
// .env
DATABASE_URL="prisma://accelerate.prisma-data.net/?api_key=..."
// lib/prisma.ts
import { PrismaClient } from '@prisma/client/edge' // edge-compatible build
import { withAccelerate } from '@prisma/extension-accelerate'
export const prisma = new PrismaClient()
.$extends(withAccelerate())
Why Accelerate matters in serverless:
- Lambda/Edge has no long-lived TCP pool. Each invocation creates N new connections to Atlas.
- Accelerate maintains a managed connection pool at the edge, reusing connections across invocations.
- Without it, a Vercel function with
maxPoolSize: 10× 100 concurrent instances = 1000 connections to Atlas. Atlas M10 limit is 640. You will exhaust the cluster. - The
prisma://protocol is required;mongodb+srv://directly from a serverless function is a production incident waiting to happen.
Migration checklist to Accelerate:
- Install
@prisma/extension-accelerate - Change
DATABASE_URLfrommongodb+srv://toprisma://(generate key in Prisma Cloud) - Use
PrismaClientfrom@prisma/client/edgeif deploying to Edge runtime - Remove
maxPoolSize/minPoolSizefrom the connection string — Accelerate manages its own pool - Keep
retryWrites=true&w=majorityin the underlying Mongo URI (configured in Prisma Cloud, not in app code)
Commandment 2 — Thou shalt declare indexes before the first findMany
Without an index, Mongo does a COLLSCAN: O(n) per query. With a B-tree index: O(log n). A findMany({ where: { userId } }) on 10M rows without an index is a production outage waiting to happen.
In schema.prisma:
model Event {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
createdAt DateTime @default(now())
type String
@@index([userId, createdAt(sort: Desc)])
@@index([type])
}
Compound index rule — ESR (Equality → Sort → Range):
- Equality fields first (
userId) - Then Sort fields (
createdAt DESC) - Then Range fields (
amount > 100)
Violating ESR makes the index unusable for the sort or the range — Mongo falls back to in-memory sort, which blows up over 32MB.
Verify, don't assume:
db.events.find({ userId: "..." }).sort({ createdAt: -1 }).explain("executionStats")
// winningPlan.stage must be "IXSCAN", never "COLLSCAN"
// totalKeysExamined ≈ nReturned (no over-scan)
Commandment 3 — Thou shalt not findMany without select and take
Pulling the entire document when you need id and email wastes bandwidth, memory, and BSON→JSON CPU. Worse: a findMany() with no take on a table that grows to 10M rows will OOM your Node process.
Always project, always limit:
const users = await prisma.user.findMany({
where: { active: true },
select: { id: true, email: true, name: true },
take: 50,
orderBy: { createdAt: 'desc' },
})
Forbidden in production code:
prisma.x.findMany()with notakeprisma.x.findMany({ where })returning full documents when one field is needed- "I'll add the limit later" — no you won't
Commandment 4 — Thou shalt paginate with cursor, not skip
skip: 10000 forces Mongo to walk and discard 10,000 documents before returning the next page: O(n + skip). Cursor pagination over an indexed field is O(log n).
Cursor pagination (correct):
const page = await prisma.post.findMany({
take: 20,
...(lastId && {
cursor: { id: lastId },
skip: 1, // skip the cursor itself
}),
orderBy: { id: 'asc' },
})
const nextCursor = page.length === 20 ? page[page.length - 1].id : null
Acceptable exceptions for skip/offset:
- Admin tools with small datasets (<10k rows)
- The user explicitly needs jumpable page numbers and you've measured it
Everything else: cursor.
Commandment 5 — Thou shalt kill N+1 with include/select, but measure the $lookup
Prisma translates relations on Mongo into $lookup aggregation stages. include solves the N+1 problem (1 query instead of N), but a $lookup against an unindexed foreign key is brutal — it scans the joined collection for every parent document.
Rules:
- Always index foreign keys (
userId,postId, etc.) with@@index. - For 1:1 or small 1:N relations that are always read together, use Prisma composite types (embedded documents) instead of separate collections — zero joins, atomic reads, this is Mongo's superpower.
- Use
selectinsideincludeto project only what you need from the joined side.
// Good: indexed FK, projected fields
const posts = await prisma.post.findMany({
where: { published: true },
select: {
id: true,
title: true,
author: { select: { id: true, name: true } },
},
take: 20,
})
For deeply nested includes (3+ levels), profile the $lookup pipeline with explain(). If it's slow, denormalize or embed.
Commandment 6 — Thou shalt batch writes or perish from round-trips
// ❌ N round-trips, N × (RTT + writeConcern majority wait)
for (const item of items) {
await prisma.event.create({ data: item })
}
Each iteration pays full network latency plus w: majority acknowledgment. 1000 items at 20ms RTT = 20 seconds. Use batch operations:
// ✅ 1 round-trip
await prisma.event.createMany({ data: events })
await prisma.user.updateMany({ where: { active: false }, data: { archived: true } })
await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } })
The only valid loop case is when you need the generated id of each insert individually AND you can't restructure to a single batch. Even then, parallelize with Promise.all (bounded concurrency) instead of await in series.
Commandment 7 — Thou shalt aggregate in the database, not in Node
The #1 antipattern in code review: loading 100k documents into Node memory to run .reduce(), .filter(), or .groupBy() in JavaScript. Mongo's aggregation engine runs on disk, in parallel, with indexes. Your Node process runs single-threaded on the heap.
// ❌ Loads everything into memory
const orders = await prisma.order.findMany({ where: { createdAt: { gte: lastMonth } } })
const totals = orders.reduce((acc, o) => {
acc[o.status] = (acc[o.status] || 0) + o.total
return acc
}, {})
// ✅ Mongo does the work, Node gets the result
const totals = await prisma.order.groupBy({
by: ['status'],
where: { createdAt: { gte: lastMonth } },
_sum: { total: true },
_count: true,
})
Same applies to count, aggregate (avg/min/max/sum), and $queryRaw for anything Prisma can't express natively.
Commandment 8 — Thou shalt not trust user input, not even in where
NoSQL injection is real. A request body like { role: { $ne: 'admin' } } passed directly into a where clause can bypass auth checks.
Three rules:
- Validate request shape with Zod / Valibot / Yup before it touches Prisma:
const QuerySchema = z.object({
role: z.enum(['user', 'editor']),
limit: z.number().int().min(1).max(100).default(20),
})
const { role, limit } = QuerySchema.parse(req.body)
await prisma.user.findMany({ where: { role }, take: limit })
- Never spread untrusted input into
where:
// ❌ NEVER
await prisma.user.findMany({ where: req.body })
- For
$queryRaw, always use the tagged template form (parameterized), never string concatenation:
// ✅ parameterized
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`
// ❌ injectable
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = '${userId}'`)
Prisma's typed query API already parameterizes, but unvalidated input shape is still a vulnerability vector.
Commandment 9 — Thou shalt use $transaction only when atomicity matters, not by reflex
MongoDB supports transactions only on replica sets, and each transaction holds locks, snapshots, and retries on TransientTransactionError. They have real cost. Use them when state must change atomically (debit account A, credit account B). Don't wrap independent reads.
// ❌ Serial, transactional for no reason — slow and lock-heavy
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id } })
const posts = await tx.post.findMany({ where: { userId: id } })
return { user, posts }
})
// ✅ Parallel, no transaction needed
const [user, posts] = await Promise.all([
prisma.user.findUnique({ where: { id } }),
prisma.post.findMany({ where: { userId: id }, take: 10 }),
])
// ✅ Transaction where atomicity is required
await prisma.$transaction([
prisma.account.update({ where: { id: from }, data: { balance: { decrement: amount } } }),
prisma.account.update({ where: { id: to }, data: { balance: { increment: amount } } }),
prisma.transfer.create({ data: { from, to, amount } }),
])
For the interactive $transaction(async tx => ...) form, set a reasonable timeout and maxWait — the defaults will surprise you under load.
Commandment 10 — Thou shalt treat DATABASE_URL as a live secret, not a constant
The connection string holds credentials, pool config, and durability flags. Treat it accordingly.
Three rules:
Never commit it. Not in
.env, not in.env.examplewith real values, not in CI logs, not in error messages. Use a secret manager (Vault, AWS Secrets Manager, Doppler, 1Password) — and rotate.Explicit production parameters — defaults are not safe defaults:
?retryWrites=true
&w=majority
&maxPoolSize=10
&minPoolSize=2
&serverSelectionTimeoutMS=5000
&socketTimeoutMS=30000
- Principle of least privilege on the Mongo user. The app does not need
dbAdminorroot. Create a role withreadWriteonly on the specific database:
db.createUser({
user: "app_prod",
pwd: "...",
roles: [{ role: "readWrite", db: "production" }]
})
Separate users for migrations (with dbAdmin) and runtime (with readWrite only). The blast radius of a leaked runtime credential drops by an order of magnitude.
Prisma + Mongo Limitations — When Prisma is the wrong tool
Prisma on MongoDB is opinionated and incomplete. A senior engineer knows where the guardrails end and where raw MongoDB becomes mandatory.
- No native migrations.
prisma migrate devanddb pushdo not support MongoDB. Every@@indexaddition must ship with a companion.jsmigration script run viamongoshor Atlas. $queryRawis severely limited. Designed for SQL; on Mongo it only supports basic read-only pipelines. For complex$lookup,$facet, or aggregations, drop to the nativemongodbdriver.- No composite
@@id. Prisma does not support composite primary keys on MongoDB. Workaround: single@id+ compound@@uniqueon business keys, or denormalize into one field. - Specialized indexes require raw MongoDB. TTL, partial, sparse, text, and 2dsphere (Commandments #11–#14) cannot be declared in
schema.prisma. Document every raw index as a comment block with its exactcreateIndexcommand. - Complex aggregations are out of scope.
groupByandaggregatecover basics. For multi-stage pipelines, use the native driver inside a repository method.
Index Specialization — 5 additional commandments
The index is the difference between a query that returns in 2ms and one that takes down your cluster. Commandment #2 says "have indexes." These five say "have the right indexes, the right way." Full details for each are in the references/patterns/ files listed below.
Commandment 11 — Thou shalt respect index selectivity, not waste B-tree depth on booleans
The leading field of an index must split the dataset finely. userId (millions of values) is great; isActive (2 values) as a leader is nearly useless — it scans ~50% of the collection. Quick test: distinct("field").length / count(). If the ratio is below ~1%, don't lead with it. Exception: partial indexes (#14) fix cardinality by indexing only the matching subset. See references/patterns/index-selectivity.md.
Commandment 12 — Thou shalt design covered queries — index serves the projection too
A covered query answers entirely from the index (totalDocsExamined: 0). To cover, the index must contain every field in where, orderBy, and select. One extra field in select outside the index kills the covering. Wider indexes cost disk and RAM — cover only your hot, read-heavy queries. See references/patterns/covered-query-design.md.
Commandment 13 — Thou shalt prefer compound indexes over index intersection
Mongo rarely uses index intersection, and when it does it's slower than a compound index. The prefix rule makes compounds powerful: [A, B, C] also serves [A] and [A, B], but never [B] alone. Design from your top 5 query shapes, not from fields. See references/patterns/compound-index-design.md.
Commandment 14 — Thou shalt use the right specialized index — TTL, partial, sparse, unique, text, 2dsphere
If your data has a kind, there's a specialized index for it: TTL for expirations, partial for filtered hot paths, sparse + unique for optional uniques, text for search (never $regex without anchor), 2dsphere for geospatial. All require raw MongoDB — Prisma's DSL only covers basic @@index. See references/patterns/specialized-indexes.md.
Commandment 15 — Thou shalt audit indexes — every one has a write cost, drop the unused
Every index is taxed on every write. Symptoms of over-indexing: write latency creeping up, replication lag, working set past RAM. Hunt unused indexes monthly with $indexStats (ops: 0 → drop). Hunt redundant prefixes. Build new indexes with { background: true } or rolling builds on replica sets. Heuristic: >6–8 indexes per collection is a design problem. See references/patterns/index-audit.md.
Bonus — The senior move: measure or it didn't happen
What you don't measure, you can't optimize. Before declaring code "production-ready":
- Dev:
log: ['query', 'warn', 'error']on the Prisma client to see every query and its duration. - Prod: export
prisma.$metrics.json()to Prometheus / Datadog. Alert on p95 query latency > 100ms. - Atlas: enable the Performance Advisor and Profiler. Alert on slow queries (>100ms) and on plans that hit
COLLSCAN. - Load test before launch, not after. k6 or Artillery against a staging cluster that mirrors prod indexes.
Emergency Playbook — Troubleshooting Common Failures
When the pager rings at 3 AM, you don't google. You run this checklist.
Symptom: Pool exhausted / MongoServerError: connection pool exhausted
- Diagnosis:
db.serverStatus().connections→current≈available. Or Atlas metrics show connections flat at limit. - Fix NOW: restart the service (kills leaked PrismaClients), then deploy the singleton fix (Commandment #1).
- Root cause: multiple
new PrismaClient()instances, missing singleton, or serverless without Accelerate. - Prevention: Commandment #1 + Accelerate in serverless.
Symptom: TransientTransactionError during $transaction
- Diagnosis: MongoDB logs show
TransientTransactionErrorwith retry attempts. - Fix NOW: increase
maxWaitandtimeoutin the transaction options, or remove the transaction if it wraps independent reads. - Root cause: transactions on replica sets have retry limits; default timeouts are too low under load.
- Prevention: Commandment #9 — use
$transactiononly for true atomicity.
Symptom: Atlas Performance Advisor flags COLLSCAN on a "fast" query
- Diagnosis:
explain("executionStats")showswinningPlan.stage: "COLLSCAN"andtotalDocsExamined>>nReturned. - Fix NOW: build the missing index (background build on replica sets), verify with
explain()again. - Root cause: missing or wrong-order index; ESR violated; query shape changed after index was built.
- Prevention: Commandment #2 + #13 — design indexes from query patterns, verify with
explain().
Symptom: Write latency spikes under load
- Diagnosis: Atlas metrics show
oplogchurn increasing; replication lag growing;totalKeysExaminednormal but insert latency up. - Fix NOW: run
$indexStatson the hot collection, drop indexes withops: 0, consolidate redundant prefixes. - Root cause: over-indexing — too many indexes on a write-heavy collection.
- Prevention: Commandment #15 — monthly
$indexStatsaudit, heuristic max 6–8 indexes per collection.
Symptom: PrismaClientKnownRequestError: P2035 (timeout)
- Diagnosis: query duration >
socketTimeoutMS(default 30s). Check Atlas Profiler for the slow query. - Fix NOW: add the missing index, add
takelimit, or switch fromskipto cursor pagination. - Root cause: unindexed query on large collection, missing
take, or deepskipoffset. - Prevention: Commandments #2, #3, #4 — index every
where, always limit, cursor pagination.
Review checklist (apply to every Prisma + Mongo file you touch)
When reviewing or writing code, walk this list:
- #1 Single
PrismaClientinstance (singleton pattern present)? - #1 Connection string has explicit
maxPoolSize,retryWrites,w=majority? - #2 Every
wherefield is covered by an@@index(and ESR-ordered if compound)? - #3 Every
findManyhas bothselect/includeprojection ANDtake? - #4 Pagination uses
cursor, notskip(unless dataset is provably small)? - #5 Foreign keys used in
includeare indexed? Embedded vs related decided deliberately? - #6 No
awaitinside loops for writes —createMany/updateMany/deleteManyused? - #7 Aggregations (
groupBy,aggregate,count) run in DB, not in JS.reduce()? - #8 User input validated with a schema before reaching
where/data? - #8 Raw queries use tagged template (
$queryRaw), never$queryRawUnsafewith interpolation? - #9
$transactionused only for true atomicity needs? Parallel reads viaPromise.all? - #10
DATABASE_URLfrom secret manager, app DB user has onlyreadWriteon its database? - #11 Leading index field is selective (not a boolean or 3-value enum)?
- #12 Hot read queries are covered (
selectfields ⊆ index keys,totalDocsExamined: 0)? - #13 Compound indexes built for actual query patterns (no reliance on intersection, prefix rule respected)?
- #14 Specialized index types used where appropriate (TTL for expirations, partial for filtered hot paths, sparse + unique for optional uniques, text for search, 2dsphere for geo)?
- #15 Indexes audited with
$indexStats— unused dropped, redundant prefixes removed, builds done rolling on prod? - Bonus Query logging in dev, metrics exported in prod, slow-query alerts configured?
Cite the failing commandment number when flagging an issue. Show the fix in code, not in prose.
References
The following files provide detailed examples, patterns, anti-patterns, and troubleshooting guides referenced throughout the commandments above:
Examples
references/examples/schema-saas.prisma.md— Production-readyschema.prismawithUser,Post,Comment,Order, andSessionmodels demonstrating all index types and raw MongoDB migration scripts.
Patterns
references/patterns/singleton-prisma-client.md— Singleton implementation, connection string templates, and Prisma Accelerate serverless config.references/patterns/index-selectivity.md— Why booleans and low-cardinality fields make poor index leaders and how to test selectivity.references/patterns/covered-query-design.md— How to design indexes that coverwhere,orderBy, andselectfor zero document fetches.references/patterns/cursor-pagination.md— Cursor-based pagination implementation withskipexceptions documented.references/patterns/compound-index-design.md— ESR rule, prefix rule, and designing compound indexes from real query patterns.references/patterns/specialized-indexes.md— TTL, partial, sparse, unique, text, and 2dsphere indexes with raw MongoDB commands.references/patterns/index-audit.md— Monthly$indexStatsaudits, redundant prefix removal, and rolling production builds.
Anti-Patterns
references/anti-patterns/n-plus-one-loop.md— Unindexed$lookupviaincludeand the embedded-document alternative.references/anti-patterns/skip-offset-pagination.md— Cost analysis of deepskipoffsets and the cursor fix.references/anti-patterns/await-in-write-loops.md— Serial writes vscreateMany/updateMany/deleteManybatching.references/anti-patterns/js-aggregation.md— Loading documents into Node to.reduce()vs nativegroupBy/aggregate.references/anti-patterns/unvalidated-where-spread.md— NoSQL injection viawhere: req.bodyand the Zod validation fix.
Troubleshooting
references/troubleshooting/pool-exhausted.md— One-command diagnosis and immediate fix for connection pool exhaustion.references/troubleshooting/transient-transaction-error.md— Timeout tuning and removing unnecessary transactions.references/troubleshooting/collscan-alerts.md— DiagnosingCOLLSCANwithexplain(), index building, and when indexes don't help.