CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/mongoose-best-practices

Mongoose and MongoDB patterns — schema design, validation, indexes, virtuals,

99

1.11x
Quality

99%

Does it follow best practices?

Impact

100%

1.11x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/mongoose-best-practices/

name:
mongoose-best-practices
description:
Mongoose and MongoDB patterns — schema design, validation, indexes, virtuals, middleware, population, lean queries, transactions, and pagination. Use when building or reviewing apps with MongoDB and Mongoose, when designing document schemas, or when migrating from SQL to MongoDB.
keywords:
mongoose, mongodb, mongoose schema, mongoose model, mongoose validation, mongoose index, mongoose populate, mongoose middleware, mongoose virtual, mongodb document, nosql, mongoose typescript, lean, transactions, pagination
license:
MIT

Mongoose Best Practices

Patterns, gotchas, and performance guidance for MongoDB with Mongoose. Ordered by impact.


1. The save() vs updateOne() Middleware Gotcha

Mongoose pre('save') and post('save') hooks only run on document.save() and Model.create(). They do NOT run on updateOne(), findOneAndUpdate(), findByIdAndUpdate(), or any update operation.

WRONG — middleware silently skipped

// This pre-save hook recalculates the total:
orderSchema.pre('save', function () {
  this.totalCents = this.items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0);
});

// BUG: This update SKIPS the pre-save hook — totalCents is now stale
await Order.findByIdAndUpdate(orderId, { $push: { items: newItem } });

RIGHT — use save() when middleware matters, or duplicate logic in update hooks

// Option A: fetch-then-save so middleware runs
const order = await Order.findById(orderId);
order.items.push(newItem);
await order.save(); // pre('save') fires, totalCents recalculated

// Option B: register a separate pre-hook for update operations
orderSchema.pre('findOneAndUpdate', async function () {
  const update = this.getUpdate() as any;
  if (update.$push?.items || update.$set?.items) {
    const doc = await this.model.findOne(this.getFilter());
    const newItems = update.$push?.items
      ? [...doc.items, update.$push.items]
      : update.$set.items;
    const total = newItems.reduce((s: number, i: any) => s + i.priceCents * i.quantity, 0);
    this.set({ totalCents: total });
  }
});

Rule: If a model has pre/post save hooks with important logic, either use save() or register equivalent hooks for findOneAndUpdate / updateOne / updateMany.


2. Always Use runValidators: true on Updates

By default, Mongoose update operations (findByIdAndUpdate, updateOne, etc.) do NOT run schema validators. This allows invalid data into the database silently.

WRONG — validators skipped on update

const userSchema = new Schema({
  email: { type: String, required: true, match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
  role: { type: String, enum: ['admin', 'user', 'moderator'] },
});

// BUG: This succeeds even though 'superadmin' is not in the enum
await User.findByIdAndUpdate(userId, { role: 'superadmin' });

RIGHT — always pass runValidators: true

await User.findByIdAndUpdate(
  userId,
  { role: 'superadmin' },
  { new: true, runValidators: true } // throws ValidationError for invalid enum
);

// Or set it globally so you never forget:
mongoose.set('runValidators', true);

Rule: Always pass { runValidators: true } on every update call, or set the global default.


3. Use .lean() for Read-Only Queries

Mongoose documents carry change tracking, getters/setters, and method overhead. For read-only operations (API responses, reports, exports), use .lean() to return plain JavaScript objects. This is 5-10x faster and uses significantly less memory.

WRONG — full Mongoose documents for read-only use

// Returns full Mongoose documents with change tracking overhead
app.get('/api/products', async (req, res) => {
  const products = await Product.find({ active: true });
  res.json(products); // toJSON() called on each document — slow
});

RIGHT — lean queries for read-only responses

app.get('/api/products', async (req, res) => {
  const products = await Product.find({ active: true }).lean();
  res.json(products); // Already plain objects — fast
});

// NOTE: .lean() documents don't have .save(), virtuals, or instance methods.
// Only use .lean() when you don't need to modify and save the document.

Rule: Default to .lean() for any query whose results are read-only (API responses, templates, reports). Only omit .lean() when you need to call .save() or use instance methods.


4. Schema Design: Indexes, Validation, and Options

Compound Indexes for Query Patterns

Create compound indexes that match your query filter + sort patterns. Field order matters — put equality filters first, then sort fields.

const orderSchema = new Schema({
  customerName: { type: String, required: true, trim: true, maxlength: 100 },
  status: {
    type: String,
    required: true,
    enum: ['received', 'preparing', 'ready', 'picked_up', 'cancelled'],
    default: 'received',
  },
  items: [{
    menuItemId: { type: Schema.Types.ObjectId, ref: 'MenuItem', required: true },
    size: { type: String, required: true, enum: ['small', 'medium', 'large'] },
    quantity: { type: Number, required: true, min: 1, max: 20 },
    priceCents: { type: Number, required: true, min: 0 },
  }],
  totalCents: { type: Number, required: true, min: 0 },
}, {
  timestamps: true, // Auto createdAt + updatedAt — never manage these manually
});

// Compound index: queries filtering by status and sorting by createdAt use this
orderSchema.index({ status: 1, createdAt: -1 });

// Unique index to prevent duplicates
userSchema.index({ email: 1 }, { unique: true });

// TTL index — auto-delete documents after 30 days (e.g., sessions, logs)
sessionSchema.index({ createdAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 });

WRONG — missing unique index, relying only on application checks

// BUG: Race condition — two requests can create duplicate emails
const existing = await User.findOne({ email });
if (!existing) {
  await User.create({ email, name }); // duplicate possible between check and create
}

RIGHT — unique index enforced at database level

userSchema.index({ email: 1 }, { unique: true });

// Now duplicates throw a MongoServerError with code 11000
try {
  await User.create({ email, name });
} catch (err: any) {
  if (err.code === 11000) {
    throw new ConflictError('Email already exists');
  }
  throw err;
}

Key Schema Rules

  • timestamps: true on all schemas — auto-manages createdAt/updatedAt
  • required: true on all non-optional fields
  • enum for finite value sets — validates at schema level
  • trim: true on string fields — strips whitespace
  • unique indexes for uniqueness constraints — not just application-level checks
  • TTL indexes for auto-expiring data (sessions, tokens, logs)

5. Embedding vs Population — When to Use Which

Embed when:

  • Data is always accessed together (e.g., order line items with the order)
  • The subdocument doesn't exist independently
  • The embedded array is bounded (< 100 items typically)

Populate (reference) when:

  • Data exists independently and is shared across documents
  • The related collection is large or frequently updated
  • You need to query the related data independently

WRONG — populating data that should be embedded

// BAD: Separate collection for address — always fetched with user, never standalone
const addressSchema = new Schema({ street: String, city: String, zip: String });
const Address = mongoose.model('Address', addressSchema);

const userSchema = new Schema({
  name: String,
  address: { type: Schema.Types.ObjectId, ref: 'Address' }, // unnecessary ref
});

// Requires an extra query (populate) every time you fetch a user
const user = await User.findById(id).populate('address');

RIGHT — embed data that belongs to the parent

const userSchema = new Schema({
  name: { type: String, required: true },
  address: {
    street: { type: String, required: true },
    city: { type: String, required: true },
    zip: { type: String, required: true, match: /^\d{5}(-\d{4})?$/ },
  },
});

// Single query, no populate needed
const user = await User.findById(id);

Rule: If the child data has no meaning without the parent and is bounded in size, embed it. If it's shared, unbounded, or independently queried, use a reference.


6. Connection Handling and Graceful Shutdown

WRONG — no error handling, no graceful shutdown

mongoose.connect('mongodb://localhost:27017/myapp');
// App runs, connection drops silently, requests hang

RIGHT — proper connection with pooling, error handling, and shutdown

import mongoose from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/myapp';

await mongoose.connect(MONGODB_URI, {
  maxPoolSize: 10,                  // connection pool size
  serverSelectionTimeoutMS: 5000,   // fail fast if no server
  socketTimeoutMS: 45000,           // close sockets after 45s inactivity
});

mongoose.connection.on('error', (err) => {
  console.error('MongoDB connection error:', err);
});

mongoose.connection.on('disconnected', () => {
  console.warn('MongoDB disconnected — attempting reconnect');
});

// Graceful shutdown — close connection before process exits
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
  process.on(signal, async () => {
    await mongoose.connection.close();
    process.exit(0);
  });
}

Rule: Always configure maxPoolSize, handle error/disconnect events, use environment variables for the URI, and close the connection on process shutdown.


7. Cursor-Based Pagination vs skip/limit

skip(N) scans and discards N documents — O(N) performance that degrades as pages increase. Use cursor-based pagination for large collections.

WRONG — skip/limit pagination at scale

// Page 1000 skips 999,000 documents — extremely slow
const page = parseInt(req.query.page) || 1;
const results = await Product.find()
  .sort({ createdAt: -1 })
  .skip((page - 1) * 20)
  .limit(20);

RIGHT — cursor-based pagination using _id or a sorted field

const limit = 20;
const cursor = req.query.cursor; // last document's _id from previous page

const query: any = {};
if (cursor) {
  query._id = { $lt: new mongoose.Types.ObjectId(cursor) };
}

const results = await Product.find(query)
  .sort({ _id: -1 })
  .limit(limit + 1) // fetch one extra to check if there's a next page
  .lean();

const hasNextPage = results.length > limit;
if (hasNextPage) results.pop();

res.json({
  data: results,
  nextCursor: hasNextPage ? results[results.length - 1]._id : null,
});

Rule: Use skip/limit only for small collections or early pages (< 1000 skipped docs). For large datasets, use cursor-based pagination with an indexed field.


8. Transactions with Replica Sets

MongoDB transactions require a replica set. Use session and withTransaction() for multi-document atomic operations.

WRONG — non-atomic multi-document operation

// BUG: If the second operation fails, the first is not rolled back
await Account.updateOne({ _id: fromId }, { $inc: { balance: -amount } });
await Account.updateOne({ _id: toId }, { $inc: { balance: amount } });

RIGHT — transaction ensures atomicity

const session = await mongoose.startSession();
try {
  await session.withTransaction(async () => {
    await Account.updateOne(
      { _id: fromId },
      { $inc: { balance: -amount } },
      { session }
    );
    await Account.updateOne(
      { _id: toId },
      { $inc: { balance: amount } },
      { session }
    );
  });
} finally {
  await session.endSession();
}

Rule: Use transactions (startSession + withTransaction) for any operation that must atomically update multiple documents. Always pass { session } to every operation inside the transaction and call endSession() in a finally block.


References

  • Mongoose Docs — Schemas
  • Mongoose Docs — Validation
  • Mongoose Docs — Middleware
  • Mongoose Docs — Populate
  • Mongoose Docs — Lean
  • Mongoose Docs — Transactions
  • MongoDB Docs — Indexes
  • MongoDB Docs — TTL Indexes

Checklist

  • timestamps: true on all schemas
  • required: true on non-optional fields
  • enum for finite value sets
  • trim: true on string fields
  • Compound indexes matching query filter + sort patterns
  • Unique indexes for uniqueness constraints (not application-level checks)
  • TTL indexes for auto-expiring documents (sessions, tokens)
  • .lean() on all read-only queries
  • runValidators: true on all update operations (or set globally)
  • save() used when pre/post save middleware must run (not updateOne/findByIdAndUpdate)
  • Embed bounded child data; reference shared/unbounded data
  • Connection configured with maxPoolSize, error handlers, and graceful shutdown
  • Cursor-based pagination for large collections (not skip/limit)
  • Transactions (startSession + withTransaction) for multi-document atomicity
  • { session } passed to every operation inside a transaction

Verifiers

  • mongoose-schema — Schema design with timestamps, validation, and indexes
  • mongoose-queries — Lean queries, runValidators, and save vs update
  • mongoose-architecture — Embedding vs population, pagination, transactions, and connections

skills

mongoose-best-practices

tile.json