Prisma vs Mongoose: A Full-Stack Developer's Perspective

Arjun DangiArjun Dangi
6 min read

As a full-stack developer, I've had the opportunity to work with both Prisma (PostgreSQL) and Mongoose (MongoDB) ORM. Each has its strengths and weaknesses, and in this blog, I'll share my experiences and insights to help you choose the right tool for your project. 🚀

Introduction to Prisma

Prisma is a next-generation ORM that provides a type-safe and intuitive way to interact with relational databases. It simplifies database management with an auto-generated query engine, supports migrations, and offers robust transaction handling. Designed primarily for SQL databases like PostgreSQL, MySQL, and SQLite, Prisma integrates seamlessly with TypeScript, making it an excellent choice for modern full-stack applications.

Introduction to Mongoose

Mongoose is a popular ODM (Object Data Modeling) library for MongoDB that provides schema validation, middleware support, and a powerful aggregation framework. It allows developers to define schemas in JavaScript or TypeScript and interact with MongoDB using an intuitive API. While Mongoose requires manual .populate() calls to resolve relations, its flexibility in handling nested documents and dynamic schemas makes it a great fit for NoSQL applications.

1. Schema Definition & Type Safety ⚙️

📌 Prisma

✅ Uses a single schema file to define all models, making it easy to manage but harder to navigate in large projects.

✅ Auto-generates TypeScript types, ensuring strict type safety and reducing runtime errors.

✅ Handles relations seamlessly, allowing easy inclusion of related data using .include.

No support for nested schemas—requires separate tables unless stored as JSON.

Example:

model User {
  id    String @id @default(uuid())
  name  String
  posts Post[]
}

model Post {
  id     String @id @default(uuid())
  title  String
  user   User   @relation(fields: [userId], references: [id])
  userId String
}

📌 Mongoose

✅ Defines schemas within JavaScript/TypeScript code, making them flexible and modular.

✅ Supports nested schemas, allowing structured data like arrays and objects directly.

No built-in type safety—missing or extra fields don’t trigger errors automatically.

Example:

const mongoose = require('mongoose');
const { Schema } = mongoose;

interface IPost {
 titile:string
 user: mongoose.Types.ObjectId
}

interface IPostSchema extends IPost, Document {
  createdAt: Date;
  updatedAt: Date;
}

const PostSchema = new Schema<IPostSchema>({
  title: String,
  user: { type: Schema.Types.ObjectId, ref: 'User' }
});

const UserSchema = new Schema({
  name: String,
  posts: [{ type: Schema.Types.ObjectId, ref: 'Post' }]
});

const User = mongoose.model('User', UserSchema);
const Post = mongoose.model('Post', PostSchema);

2. Querying & Aggregation 🔍

📌 Prisma

✅ Queries are simpler, type-safe, and reduce runtime errors.

Limited aggregation capabilities—often requires multiple queries for filtering and calculations.

Date queries are restricted, lacking operators for extracting hours/minutes directly.

Example of Pagination Query:

const posts = await prisma.post.findMany({
  skip: 10,
  take: 10,
  include: { user: true },
});

📌 Mongoose

Powerful aggregation pipeline with operators like $match, $group, $facet, and $project.

✅ Directly supports querying date fields, enabling complex time-based filtering.

Example of Aggregation Query with Facet for Pagination:

const result = await Post.aggregate([
  { $match: { createdAt: { $gte: new Date("2024-01-01") } } },
  {
    $facet: {
      metadata: [{ $count: "total" }],
      data: [{ $skip: 10 }, { $limit: 10 }]
    }
  }
]);

⚠️ In Prisma, you need two separate queries to get the paginated data and total count:

const totalPosts = await prisma.post.count({ where: { createdAt: { gte: new Date("2024-01-01") } } });
const posts = await prisma.post.findMany({
  skip: 10,
  take: 10,
  where: { createdAt: { gte: new Date("2024-01-01") } }
});

3. Unique Constraints & Error Handling 🚦

📌 Prisma

✅ Enforces unique constraints at the database level.

✅ Provides specific error codes, eliminating the need for pre-check queries.

✅ Prevents inserting related documents if the referenced ID doesn’t exist.

✅ Supports multi-field unique constraints using @@unique(["field1", "field2"]).

Example:

//multi-field unique constraints
model User {
  id    String @id @default(uuid())
  email String @unique
  username String
  @@unique([email, username])
}

try {
  await prisma.user.create({
    data: { email: "test@example.com", username: "testUser" }
  });
} catch (error) {
  if (error.code === 'P2002') {
    console.log("Duplicate email or username!");
  }
}

Mongoose

✅ Unique constraints rely on MongoDB indexes,

❌ No standard error codes—developers must check manually before inserting.

❌ Allows inserting a document with a non-existent referenced ID, requiring extra validation.

Example:

const UserSchema = new Schema({
  email: { type: String, unique: true },
  username: String
});
UserSchema.index({ email: 1, username: 1 }, { unique: true });

const existingUser =  User.findOne({ email: "test@example.com",username:"username" });

if(existingUser){
//
} 

//create new

⚠️ Referenced ID doesn’t exist.

📌 Prisma

✅ Throws an error if referenced ID doesn’t exist.

try {
  await prisma.post.create({
    data: { title: "Post", userId: "non-existent-id" }
  });
} catch (error) {
  console.log("Error: User ID does not exist!");
}

📌 Mongoose

❌ Allows inserting even if referenced ID doesn’t exist.

const post = new Post({ title: "Post", user: "non-existent-id" });
post.save().then(() => console.log("Saved successfully!"));

4. Cascading Deletes 🗑️

📌 Prisma

✅ Supports cascading delete by specifying onDelete: Cascade.

✅ Default behavior is strict, meaning you can't delete a user if related records exist unless cascade is set.

Example:

model User {
  id    String @id @default(uuid())
  posts Post[]
}

model Post {
  id     String @id @default(uuid())
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId String
}

📌 Mongoose

❌ No built-in support for cascading deletes.

❌ Deleting a user does not delete related posts automatically.

✅ Requires manual handling deletion in middleware or application logic.


4. Handling IDs & Relations 🔗

1️⃣ Relation Handling

📌 Prisma

✅ Prisma fetches full related objects when using .include(), making data retrieval seamless.

const post = await prisma.post.findMany({
  include: { author: true },
});

📌 Mongoose

❌ By default, Mongoose only stores the foreign key and requires .populate() to fetch related documents.

const post = await Post.find().populate("author");

⚠️ Issue: If you define userId in another table and use .populate("userId"), it replaces userId with the full User object, leading to confusion.

Example Output:

{
  "title": "New Post",
  "userId": {
    "_id": "123",
    "name": "John Doe"
  }
}

⚠️ Workaround: You can use virtual fields in Mongoose to automatically populate relations.

UserSchema.virtual("posts", {
  ref: "Post",
  localField: "_id",
  foreignField: "authorId"
});

4. Handling IDs Type 🔗

📌 Prisma

✅ Handles IDs as number/strings, no need for conversion.

const user = await prisma.user.findUnique({ where: { id: "user-id-string" } });

📌 Mongoose

❌ MongoDB IDs are ObjectIds, but front-end usually sends IDs as strings. You must convert them manually.

const { ObjectId } = require("mongoose");
const user = await User.findOne({ _id: new ObjectId("user-id-string") });

5. Transactions 🔄

📌 Prisma

✅ Provides a simple prisma.transaction() method to handle transactions cleanly.

await prisma.$transaction([
  prisma.user.create({ data: { name: "John" } }),
  prisma.post.create({ data: { title: "Post", authorId: "user-id" } })
]);

//OR

await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({ data: { name: "John" } });
  await tx.post.create({ data: { title: "Post", authorId: user.id } });
});

📌 Mongoose

❌ Requires handling transactions manually using MongoDB sessions.

const session = await mongoose.startSession();
session.startTransaction();
try {
  await User.create([{ name: "John" }], { session });
  await Post.create([{ title: "Post", authorId: "user-id" }], { session });
  await session.commitTransaction();
} catch (error) {
  await session.abortTransaction();
}
session.endSession();

Conclusion 🎯

Both Prisma and Mongoose have their use cases. If you need strict type safety, relational integrity, and easy cascading deletes, Prisma is a great choice. However, if your project relies heavily on aggregation, nested schemas, and flexible NoSQL structures, Mongoose is the way to go. 💡

0
Subscribe to my newsletter

Read articles from Arjun Dangi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Arjun Dangi
Arjun Dangi