How to Migrate from Mongoose to Prisma
TL;DR
Migrating from Mongoose to Prisma means switching from document-oriented to schema-first ORM. If you're staying on MongoDB, Prisma now supports it natively. If switching to PostgreSQL (a common reason to migrate), you'll need to convert your documents to relational schemas. In both cases, Prisma gives you fully-typed queries, generated migrations, and Prisma Studio for free. The hardest part isn't the syntax — it's the data model translation.
Key Takeaways
- Schema-first: Prisma uses
schema.prismafile instead of inline model definitions - Full TypeScript types: Prisma generates exact types, no
anyor manual typing - Prisma supports MongoDB: Can migrate Mongoose → Prisma without changing databases
- Relational shift: If moving to PostgreSQL, embedded documents → join tables
- Prisma Studio: Free GUI for your database (replacing Mongo Compass for development)
Why Migrate from Mongoose to Prisma?
Mongoose has served the Node.js community well for over a decade. It brought schema definitions and validation to MongoDB at a time when the ecosystem needed structure. But the JavaScript and TypeScript ecosystem has moved on, and Prisma addresses several pain points that Mongoose leaves open.
The most significant win is TypeScript integration. Mongoose types are notoriously imprecise — Document types carry dozens of extra properties, and getting strong types on query results requires careful setup. Prisma generates its client from your schema, so every query result has exact TypeScript types with zero configuration. When you call prisma.user.findUnique(), the return type is exactly the shape of your User model, with optional relations only appearing when you include them.
Migration tooling is another major advantage. Mongoose has no built-in migration system — teams typically rely on manual scripts or third-party tools like migrate-mongo. Prisma's migration system (prisma migrate dev) creates versioned SQL migration files that you commit to git, apply automatically in CI, and can roll back if needed. This is table-stakes for production database management.
Prisma Studio rounds out the developer experience story. It's a free local GUI that opens in your browser with npx prisma studio. You can browse records, edit data, follow relations, and filter — all without leaving your development environment.
One critical decision point before you begin: Prisma MongoDB does not support embedded documents. If your application relies heavily on nested subdocuments (like user.address.city stored as an embedded object inside the user document), you have two options — stay on Mongoose for MongoDB, or migrate to PostgreSQL and normalize your schema. This guide covers both paths.
Option A: Mongoose MongoDB → Prisma MongoDB
This is the lower-friction path. You keep your data in MongoDB and gain Prisma's typed client and schema tooling without a database switch. The main trade-off: embedded documents are not supported.
Start by running prisma introspect against your existing MongoDB database. Prisma will inspect your collections and generate a schema.prisma file that approximates your document structure. Because MongoDB is schemaless, the introspected schema may be incomplete — Prisma samples documents and infers types, but it can miss optional fields that don't appear in the sampled documents. Review the output carefully.
Here is what the schema looks like for MongoDB:
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
email String @unique
role Role @default(user)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
}
enum Role {
user
admin
}
model Post {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
content String
published Boolean @default(false)
authorId String @db.ObjectId
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
Notice the @db.ObjectId annotations — these tell Prisma the field maps to MongoDB's ObjectId type. The @map("_id") on the id field maps Prisma's convention to MongoDB's _id. With this schema in place, run npx prisma generate to create the typed client.
The key limitation: any embedded subdocuments in your existing Mongoose schema need to be either flattened into top-level fields or moved to separate collections with references. A Mongoose schema with profile: { bio: String, avatar: String } becomes individual fields bio and avatar on the User model, or a separate Profile collection linked by userId.
Option B: Mongoose MongoDB → Prisma PostgreSQL
This is a larger migration but often the right long-term decision. Relational data belongs in a relational database. If your MongoDB documents have predictable schemas, referential relationships, and transactional requirements, PostgreSQL will serve you better than MongoDB.
The core conceptual shift is embedded arrays become separate tables. A Mongoose user document with embedded posts becomes a users table and a separate posts table with a authorId foreign key.
Here is the full before-and-after:
// BEFORE — Mongoose model definition
import mongoose, { Schema, Document } from 'mongoose';
interface IUser extends Document {
name: string;
email: string;
role: 'user' | 'admin';
profile: {
bio?: string;
avatar?: string;
website?: string;
};
posts: mongoose.Types.ObjectId[];
createdAt: Date;
updatedAt: Date;
}
const UserSchema = new Schema<IUser>(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
profile: {
bio: String,
avatar: String,
website: String,
},
posts: [{ type: Schema.Types.ObjectId, ref: 'Post' }],
},
{ timestamps: true }
);
export const User = mongoose.model<IUser>('User', UserSchema);
// AFTER — Prisma schema (PostgreSQL)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String
email String @unique
role Role @default(user)
bio String? // Flat — embedded doc fields become columns
avatar String?
website String?
posts Post[] // Relation — replaces ObjectId array
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Role {
user
admin
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
With the schema defined, run npx prisma migrate dev --name init to generate and apply the SQL migration. Prisma creates a file in prisma/migrations/ that you commit to version control.
Handling ObjectId → UUID
MongoDB uses 24-character hex ObjectIds as primary keys. PostgreSQL typically uses UUIDs (for global uniqueness) or auto-incrementing integers (for simplicity and performance). This ID format change affects every foreign key reference in your migrated data.
For new projects, choose @default(cuid()) or @default(uuid()) in your Prisma schema and start fresh. For existing data migrations, you need a mapping strategy. The most common approach is a two-step migration: first add a legacyId field that stores the original ObjectId string, migrate all data while preserving the original IDs, update application code to use the new UUID-based IDs, then drop legacyId once migration is verified.
model User {
id String @id @default(uuid())
legacyId String? @unique // Temporary — stores original MongoDB ObjectId
name String
email String @unique
}
This gives you a safe rollback path: if something breaks, you can look up records by either the new UUID or the original ObjectId until you're confident the migration is complete.
Query Translation Table
Migrating query code is the most mechanical part of the migration. Here is a side-by-side reference for the most common patterns:
| Operation | Mongoose | Prisma |
|---|---|---|
| Find by ID | User.findById(id) | prisma.user.findUnique({ where: { id } }) |
| Find one by field | User.findOne({ email }) | prisma.user.findUnique({ where: { email } }) |
| Find many with filter | User.find({ role: 'admin' }) | prisma.user.findMany({ where: { role: 'admin' } }) |
| Sort and limit | .sort({ name: 1 }).limit(10) | { orderBy: { name: 'asc' }, take: 10 } |
| Create | User.create({ name, email }) | prisma.user.create({ data: { name, email } }) |
| Update by ID | User.findByIdAndUpdate(id, { $set: data }) | prisma.user.update({ where: { id }, data }) |
| Update many | User.updateMany(filter, { $set: data }) | prisma.user.updateMany({ where: filter, data }) |
| Delete by ID | User.findByIdAndDelete(id) | prisma.user.delete({ where: { id } }) |
| Count | User.countDocuments({ role: 'admin' }) | prisma.user.count({ where: { role: 'admin' } }) |
| Populate relation | .populate('posts') | { include: { posts: true } } |
The populate → include shift is worth highlighting. Mongoose's populate executes a second query (N+1 risk if used in a loop). Prisma's include generates a SQL JOIN, fetching the related records in a single query. For applications that were doing multiple populate calls per request, this alone can produce noticeable performance improvements.
// CRUD Operations side by side:
// ─── CREATE ───────────────────────────────────────────────────────────
// Mongoose:
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
role: 'admin',
});
// Prisma:
const user = await prisma.user.create({
data: {
name: 'Alice',
email: 'alice@example.com',
role: 'admin',
},
});
// ─── READ ─────────────────────────────────────────────────────────────
// Mongoose:
const user = await User.findById(id);
const userByEmail = await User.findOne({ email: 'alice@example.com' });
const admins = await User.find({ role: 'admin' }).sort({ name: 1 }).limit(10);
// Prisma:
const user = await prisma.user.findUnique({ where: { id } });
const userByEmail = await prisma.user.findUnique({ where: { email: 'alice@example.com' } });
const admins = await prisma.user.findMany({
where: { role: 'admin' },
orderBy: { name: 'asc' },
take: 10,
});
// ─── UPDATE ───────────────────────────────────────────────────────────
// Mongoose:
await User.findByIdAndUpdate(id, { $set: { name: 'Bob' } }, { new: true });
await User.updateMany({ role: 'user' }, { $set: { active: true } });
// Prisma:
await prisma.user.update({ where: { id }, data: { name: 'Bob' } });
await prisma.user.updateMany({ where: { role: 'user' }, data: { active: true } });
// ─── DELETE ───────────────────────────────────────────────────────────
// Mongoose:
await User.findByIdAndDelete(id);
await User.deleteMany({ role: 'admin' });
// Prisma:
await prisma.user.delete({ where: { id } });
await prisma.user.deleteMany({ where: { role: 'admin' } });
Relations and Populate → Include
One of the most immediate quality-of-life improvements after migration is replacing populate with include. Beyond avoiding N+1 queries, Prisma's include is composable and fully typed:
// Mongoose — populate (N+1 risk):
const user = await User.findById(id).populate('posts');
// Prisma — include (single JOIN query):
const user = await prisma.user.findUnique({
where: { id },
include: {
posts: true,
},
});
// Nested includes with filters:
const user = await prisma.user.findUnique({
where: { id },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
});
// Select specific fields to reduce payload:
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
posts: {
select: { id: true, title: true },
},
},
});
Mongoose Middleware → Prisma Extensions
Mongoose's pre/post hooks are a common pattern for password hashing, timestamps, and audit logging. Prisma replaces these with the extensions API:
// Mongoose pre-save hook:
UserSchema.pre('save', async function () {
if (this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 12);
}
});
// Prisma extensions (current API):
const prismaWithHooks = prisma.$extends({
query: {
user: {
async create({ args, query }) {
if (args.data.password) {
args.data.password = await bcrypt.hash(args.data.password, 12);
}
return query(args);
},
},
},
});
Package Health
| Package | Weekly Downloads | TypeScript | Migrations | GUI | MongoDB Support |
|---|---|---|---|---|---|
| prisma | ~7M | First-class (generated) | Yes (versioned) | Prisma Studio | Yes (limited) |
| mongoose | ~3.5M | Via @types/mongoose | No (manual) | Compass (external) | Yes (full) |
| @prisma/client | ~7M | First-class | — | — | — |
Migration Checklist
A successful Mongoose-to-Prisma migration follows a predictable sequence. Work through these steps in order:
- Inventory your Mongoose schemas — list all models, embedded subdocuments, and custom validators. Flag any embedded document patterns that need a strategy decision.
- Choose your path — MongoDB (Option A) or PostgreSQL (Option B). If your schema is heavily embedded, plan the normalization work.
- Install Prisma —
npm install prisma @prisma/client, runnpx prisma init, configureDATABASE_URL. - Write your Prisma schema — translate each Mongoose schema to a Prisma model. Use
prisma introspectfor MongoDB Option A. - Handle IDs — decide on UUID vs cuid vs integer. Add
legacyIdfields if migrating existing data. - Run migrations —
npx prisma migrate dev --name init(PostgreSQL) ornpx prisma db push(MongoDB). - Translate queries — work through the codebase file by file, replacing Mongoose calls with Prisma equivalents using the query table above.
- Migrate middleware — convert pre/post hooks to Prisma extensions.
- Run tests — your existing test suite should validate the migration. Write new tests for any behavior that changed.
- Verify in staging — run a full migration dry-run against a copy of production data before cutting over.
When to Choose Prisma vs Stay on Mongoose
Prisma is the right choice when: your team uses TypeScript seriously and wants generated types, you need structured migrations for production database management, you are considering a move to PostgreSQL, or your MongoDB schema is already normalized (not heavily embedded).
Stay on Mongoose when: your application is built on deeply nested embedded documents that would require significant normalization, you need the full MongoDB feature set (text search, aggregation pipelines, geospatial queries), or you have a large existing codebase where the migration cost exceeds the benefit.
The migration is not irreversible. Many teams run both ORMs in parallel during a gradual migration — new modules use Prisma while legacy modules continue using Mongoose until they're refactored.
One practical approach for gradual adoption: introduce Prisma for all new database models and new features, while leaving existing Mongoose models untouched. Both can share the same database connection as long as they're not accessing the same collection in conflicting ways. Over several sprints, you can incrementally port Mongoose models to Prisma as those parts of the codebase are touched for other reasons. This avoids a big-bang migration and spreads the refactoring cost across normal development work.
Explore related guides: compare the two ORMs directly on PkgPulse's Mongoose vs Prisma comparison, read the full Mongoose vs Prisma 2026 breakdown, or see what developers are building with the Prisma package page.
See the live comparison
View mongoose vs. prisma on PkgPulse →