
Using Prisma with MongoDB: Setup, Pros, and Cons
Prisma earned its reputation on SQL databases: a readable schema file, a generated client with excellent TypeScript types, and autocomplete that makes queries feel almost effortless. So when a team that already uses Prisma with PostgreSQL starts a MongoDB project, the obvious question is whether they can keep the same workflow.
They can, mostly. Prisma supports MongoDB as a data source, with the same schema language, the same generated client, and nearly the same query API. But MongoDB isn't a relational database, and the places where Prisma's relational worldview meets MongoDB's document model create trade-offs you should understand before committing. There's also a versioning wrinkle right now: MongoDB support lives in Prisma ORM v6, and Prisma 7 did not ship MongoDB support at launch.
This guide covers setting up Prisma with MongoDB, modeling documents, relations, and embedded types, running queries, dropping down to raw MongoDB commands, and an honest look at the pros and cons.
A Note on Versions
Before installing anything, check the version situation. Prisma 7 reworked a lot of the ORM's internals, and at its release, MongoDB was not among the supported databases. Support may arrive in a later release, but until the Prisma docs confirm it, pin Prisma v6 for MongoDB projects and check the current documentation before upgrading.
npm install --save-dev prisma@6
npm install @prisma/client@6
Pin both packages to the same major version. Mismatched prisma and @prisma/client versions are a common source of confusing generate errors. In package.json, that looks like:
{
"devDependencies": { "prisma": "^6.0.0" },
"dependencies": { "@prisma/client": "^6.0.0" }
}
Everything in this guide targets Prisma 6.
MongoDB Must Be a Replica Set
Prisma uses transactions internally for nested writes (creating a user and their posts in one call, for example), and MongoDB only supports transactions on replica sets and sharded clusters. So Prisma requires a replica set, even in development.
Atlas clusters, including the free M0 tier, are replica sets already. For local development, a single-node replica set in Docker works fine:
# docker-compose.yml
services:
mongo:
image: mongo:8
command: ["--replSet", "rs0", "--bind_ip_all"]
ports:
- "27017:27017"
healthcheck:
test: >
mongosh --quiet --eval "try { rs.status().ok } catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'localhost:27017' }] }).ok }"
interval: 5s
retries: 10
The health check initiates the replica set on first start. Then connect with directConnection=true so the driver doesn't try to discover other members:
DATABASE_URL="mongodb://localhost:27017/blog?replicaSet=rs0&directConnection=true"
If you skip this step, Prisma's first nested write fails with an error saying the database must be a replica set. It's the single most common Prisma plus MongoDB setup problem.
Initializing the Project
npx prisma init --datasource-provider mongodb
This creates prisma/schema.prisma and a .env file. Put your connection string in DATABASE_URL, and note that the database name must be part of the URI path (/blog above), since Prisma uses it as the target database.
Modeling Documents in schema.prisma
Here's a schema for a small blog:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String @unique
name String
profile Profile?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
slug String @unique
body String
tags String[]
published Boolean @default(false)
publishedAt DateTime?
author User @relation(fields: [authorId], references: [id])
authorId String @db.ObjectId
comments Comment[]
updatedAt DateTime @updatedAt
@@index([authorId, publishedAt])
@@map("posts")
}
type Profile {
bio String?
website String?
social SocialLinks?
}
type SocialLinks {
github String?
mastodon String?
}
type Comment {
author String
text String
createdAt DateTime @default(now())
}
The MongoDB-specific parts:
- The
idfield maps to_idwith@map("_id"), uses@db.ObjectIdto store a realObjectId, and@default(auto())to have one generated. In your TypeScript code, the ID is a plainstring. Prisma converts at the boundary. - Relation scalars like
authorIdalso need@db.ObjectId, or Prisma stores them as strings and the references won't match_idvalues. typeblocks define composite types, which are embedded documents.Profileis stored inside the user document, andcommentsis an array of embedded objects inside each post. This is how you model MongoDB's embedding in Prisma.String[]maps to a native array of strings.@@map("posts")sets the collection name. Without it, Prisma uses the model name (Post) as the collection name.
Pushing the Schema
Here's a major difference from Prisma on SQL: Prisma Migrate doesn't support MongoDB. There are no migration files. Instead, you use db push:
npx prisma db push
The output looks roughly like this:
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MongoDB database "blog" at "localhost:27017"
Applying the following changes:
[+] Collection `User`
[+] Collection `posts`
[+] Unique index `User_email_key` on ({"email":1})
[+] Unique index `posts_slug_key` on ({"slug":1})
[+] Index `posts_authorId_publishedAt_idx` on ({"authorId":1,"publishedAt":1})
Your database indexes are now in sync with your Prisma schema.
db push creates collections and indexes to match your schema and regenerates the client. It doesn't transform existing documents. If you rename a field, old documents keep the old name, and it's up to you to backfill them with a script. That's consistent with how MongoDB itself works, but it's a real shift if you're used to prisma migrate dev generating SQL for you.
For an existing database, npx prisma db pull introspects collections by sampling documents and writes a schema for you. Treat the result as a starting point: it guesses types from the data it samples and can't infer relations.
Creating a Client
Instantiate one PrismaClient per process, exactly like you'd create one MongoClient:
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
The globalThis caching prevents hot-reloading frameworks from creating a new client, and a new connection pool, on every code change.
Querying
If you know Prisma from SQL, this part will feel familiar.
Creating Records
const ada = await prisma.user.create({
data: {
email: "ada@example.com",
name: "Ada Lovelace",
profile: {
bio: "First programmer",
social: { github: "ada" },
},
posts: {
create: [
{
title: "Notes on the Engine",
slug: "notes-on-the-engine",
body: "...",
tags: ["history", "computing"],
comments: [{ author: "Charles", text: "Brilliant." }],
},
],
},
},
include: { posts: true },
});
That single call creates a user document with an embedded profile and a post document in a separate collection, inside a transaction. The embedded profile and comments are set inline, while the related posts use the nested create syntax.
Reading Records
const recent = await prisma.post.findMany({
where: {
published: true,
tags: { has: "computing" },
author: { email: { endsWith: "@example.com" } },
},
select: {
title: true,
slug: true,
publishedAt: true,
author: { select: { name: true } },
},
orderBy: { publishedAt: "desc" },
take: 10,
});
Everything here is typed. Misspell publishedAt and your editor underlines it. Select a field and the return type contains exactly that field.
Filtering on composite types uses is for single embedded objects and some, every, or none for arrays:
const withGithub = await prisma.user.findMany({
where: { profile: { is: { social: { is: { github: { not: null } } } } } },
});
const discussed = await prisma.post.findMany({
where: { comments: { some: { author: "Charles" } } },
});
Updating Embedded Arrays
Composite list fields support push, set, updateMany, and deleteMany:
await prisma.post.update({
where: { slug: "notes-on-the-engine" },
data: {
comments: { push: { author: "Mary", text: "Agreed!" } },
},
});
That maps to a $push on the document, which is exactly what you'd want.
Dropping Down to Raw MongoDB
Prisma's query API covers typical CRUD well, but MongoDB has far more to offer: aggregation pipelines, geospatial queries, $lookup with custom pipelines, and admin commands. Prisma exposes three escape hatches:
// Raw find with MongoDB query syntax
const drafts = await prisma.post.findRaw({
filter: { published: false, tags: { $size: 0 } },
options: { projection: { title: 1 } },
});
// Raw aggregation pipeline
const tagCounts = await prisma.post.aggregateRaw({
pipeline: [
{ $match: { published: true } },
{ $unwind: "$tags" },
{ $group: { _id: "$tags", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 10 },
],
});
// Any database command
await prisma.$runCommandRaw({
collMod: "posts",
validationLevel: "moderate",
});
The catch: raw results aren't typed, and they come back in Extended JSON form, so an ObjectId appears as { "$oid": "..." } and dates as { "$date": "..." }. You'll need to type and convert the results yourself.
Pros
Excellent type safety. The generated client is the best reason to use Prisma. Filters, selects, and includes are all fully typed, and the return type follows your select precisely.
One schema file as the source of truth. schema.prisma documents your data model in a readable form that doubles as index management. New team members can understand the data by reading one file.
Familiar workflow for polyglot teams. If you already use Prisma with PostgreSQL, the query API and tooling carry over. Switching between services on different databases is less of a context switch.
Embedded documents are first-class. Composite types map naturally to MongoDB's embedding, with typed filters and array updates.
Prisma Studio (npx prisma studio) gives you a quick data browser during development.
Cons
No migrations. db push syncs indexes and collections but never transforms data. Schema evolution is on you, with scripts or a separate migration tool. See Database Migrations in MongoDB for approaches.
Replica set required. Even for local development and tests, you need a replica set. That's easy with Docker or Atlas, but it's one more moving part, and it complicates in-memory test setups.
Relations are emulated. MongoDB has no foreign keys. Prisma resolves include with additional queries and emulates referential actions (like onDelete: Cascade) in the client. Writes that bypass Prisma won't respect them, and emulation adds queries.
Limited access to MongoDB's power. Aggregations beyond simple groupBy, change streams, geospatial queries, Atlas Search, and many update operators require raw queries that lose type safety, or aren't available at all.
Relational assumptions. Prisma nudges you toward normalized models with relations, while good MongoDB design often favors embedding and denormalization. It's possible to model well in Prisma, but the tool's defaults pull in a relational direction.
Version uncertainty. MongoDB support sits on Prisma 6 while the rest of the ecosystem moves to Prisma 7. Pinning is safe in the short term, but you should plan for either an upgrade path when support arrives or a switch to another tool.
When Prisma Is a Good Fit
- TypeScript-first teams who value the generated client above all else.
- CRUD-heavy apps with well-defined models, moderate relations, and limited need for complex aggregations.
- Organizations standardizing on Prisma across several databases.
When to Use Something Else
- Analytics-heavy apps built on aggregation pipelines, or apps relying on change streams, Atlas Search, or geospatial features. The native driver is a better fit.
- Projects where schema evolution needs real migration tooling.
- Teams who want MongoDB-native modeling with hooks and validation. Mongoose is more at home there.
- Anyone who needs to be on the latest Prisma major version today.
Common Pitfalls
Forgetting @db.ObjectId on relation fields. Without it, authorId is stored as a string and never matches the author's _id. Relations silently return nothing.
Running against a standalone mongod. Nested writes fail. Use a replica set, even a single-node one.
Expecting db push to rename fields. It won't touch existing documents. Plan data backfills separately.
Omitting the database name from the URI. Prisma needs it in the connection string path to know which database to use.
Mixing Prisma major versions. Keep prisma and @prisma/client on the same major version, and don't upgrade to 7 on a MongoDB project until the docs confirm support.
Treating raw results as typed. findRaw and aggregateRaw return Extended JSON. Convert $oid and $date wrappers before using the values.
Conclusion
Prisma with MongoDB gives you a genuinely great developer experience for typed CRUD: a clear schema file, embedded composite types, and a client that catches mistakes at compile time. In exchange, you accept a replica set requirement, no migration tooling, emulated relations, and raw-query escape hatches for anything beyond the basics. The current version gap (MongoDB support on Prisma 6, not yet on Prisma 7) is the other factor to weigh for any new project.
If you're evaluating it, spin up the Docker replica set from this guide, model your two most important collections in schema.prisma, and write the three queries your app runs most. If those feel natural and none of them need aggregateRaw, Prisma is probably a good fit. If you're reaching for raw queries already, the native driver or Mongoose will likely serve you better.


