MongoDB and Mongoose with Express

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 20.19 or later, the minimum of the current Mongoose release, and it states where Express 4 (latest 4.22.3) or older Mongoose versions behave differently. On the check date 2026-10-29 the latest dist-tag of the mongoose package was 9.11.4, which uses the MongoDB Node.js driver 7.7, and the 8x tag was 8.24.7. The page covers both Mongoose 8 and 9, because most published code was written for versions between 3 and 8 and a project is often migrated one major version at a time. Everything was checked against the official documentation and the package repositories on 2026-10-29. MongoDB is the database of the classic MEAN and MERN stacks, and Mongoose is the object-document mapper that most Express tutorials and books use with it: it gives each collection a schema, a model class, validation and query helpers on top of the official driver. The page builds the data layer of the Bookshelf API that grows across this section: books with an author and a price, a geolocated list of bookstores, and a report endpoint driven by an aggregation pipeline. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. For driver-level access without Mongoose, other databases and the connection-pool rules that apply to every engine, see Database Integration.

Where Mongoose sits in an Express application

An Express application talks to MongoDB through two layers. The official driver (mongodb) owns the sockets, the connection pool, server discovery and the wire protocol. Mongoose adds a schema (the shape and rules of a document), a model (a class bound to one collection that creates, finds and updates documents), middleware hooks (also called pre and post hooks) and a casting step that converts strings from the request into the types of the schema before a query runs. A route handler never touches the driver directly: it calls a model method, awaits the returned promise and sends the result. Because Express 5 forwards a rejected promise to the error handlers, a failed query needs no try/catch in the handler, only an error handler that knows the Mongoose error types, which this page defines.

flowchart LR A["HTTP request"] --> B["Express route handler"] B --> C["Mongoose model: cast, validate, hooks"] C --> D["MongoDB driver: connection pool"] D --> E[("MongoDB or Atlas cluster")] E --> D D --> C C --> B B --> F["JSON response"] C -. "CastError, ValidationError, duplicate key" .-> G["Error handler maps to 400, 404, 409, 422"] G --> F

Installing and choosing a version

npm install express mongoose
npm install --save-dev mongodb-memory-server

Mongoose 9 requires Node.js 20.19 or later and Mongoose 8 accepts older Node.js releases, so the Node.js 18 minimum of Express 5 is enough only for Mongoose 8. The two major versions share almost the whole API described here; the differences that matter are listed in What changed recently. When this page shows version-specific behaviour it says so. A MongoDB server is required as well: the quickest options are a free MongoDB Atlas cluster, a container started from the official mongo image, or the in-memory server used by tests (see Testing).

Connecting before listening

The most common structural mistake in the books is calling mongoose.connect() somewhere in the middle of the file and starting the HTTP server immediately. The server then accepts requests while the connection is still being established. Mongoose buffers model operations until the connection is ready, so the first requests do not fail, but they wait silently, and a wrong connection string produces a buffering timeout (bufferTimeoutMS, ten seconds by default) after the server already looked healthy. The robust order is the same for every database: read the configuration, connect, check that the connection works, and only then call app.listen().

// db.mjs
import mongoose from 'mongoose';

export async function connectDatabase(uri) {
  if (!uri) {
    throw new Error('MONGODB_URI is not set');
  }

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

  await mongoose.connect(uri, {
    serverSelectionTimeoutMS: 5000, // fail fast when the server is unreachable
    maxPoolSize: 10,                // driver connection pool size per process
    autoIndex: process.env.NODE_ENV !== 'production', // see the indexes section
  });
  return mongoose.connection;
}

export async function disconnectDatabase() {
  await mongoose.disconnect();
}
// server.mjs
import { createServer } from 'node:http';
import { app } from './app.mjs';
import { connectDatabase, disconnectDatabase } from './db.mjs';

const port = Number(process.env.PORT ?? 3000);

await connectDatabase(process.env.MONGODB_URI);

const server = createServer(app);
server.listen(port, () => {
  console.log(`Bookshelf API listening on port ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, shutting down`);
  server.close(async () => {
    await disconnectDatabase();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref(); // do not hang forever
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

The connection string is read from an environment variable and never written into the source. Run the file with node --env-file=.env server.mjs, where .env is excluded from version control and holds a placeholder such as MONGODB_URI=mongodb://127.0.0.1:27017/bookshelf for a local server, or the mongodb+srv:// string that Atlas shows for a cluster (with a database user created for the application, not the account owner). Splitting app.mjs (the Express application, exported and never listening) from server.mjs (the process entry point) lets tests import app without opening a port.

A few connection facts worth knowing:

  • mongoose.connect() returns a promise and the driver retries server selection until serverSelectionTimeoutMS. After the first successful connection, the driver reconnects automatically after a network failure, so application code does not reconnect by hand.

  • useNewUrlParser, useUnifiedTopology and useCreateIndex (all seen in older tutorials) do nothing in current versions: the options were removed from the driver and Mongoose ignores or rejects them. useMongoClient disappeared in Mongoose 5.

  • An application normally uses the default connection, created by mongoose.connect(). Use mongoose.createConnection() only when one process talks to several databases; models are then registered on the connection instead of on mongoose.

  • In serverless or short-lived environments, keep the connection promise at module level so that warm invocations reuse it instead of opening a new pool on every request.

Schemas and models

A schema declares the paths of a document, their types and their rules. A model compiles a schema into a class bound to a collection.

// models/book.mjs
import mongoose from 'mongoose';

const { Schema } = mongoose;

const bookSchema = new Schema(
  {
    title: { type: String, required: [true, 'title is required'], trim: true, maxlength: 200 },
    author: { type: String, required: true, trim: true },
    isbn: {
      type: String,
      required: true,
      unique: true,
      match: [/^(?:\d{9}[\dX]|\d{13})$/, 'isbn must be an ISBN-10 or ISBN-13 without hyphens'],
    },
    price: { type: Number, required: true, min: [0, 'price cannot be negative'] },
    genres: { type: [String], default: [] },
    status: { type: String, enum: ['draft', 'published', 'archived'], default: 'draft' },
    publishedAt: Date,
  },
  {
    timestamps: true, // adds createdAt and updatedAt, maintained by Mongoose
    toJSON: {
      virtuals: true,
      versionKey: false,
      transform(doc, ret) {
        delete ret._id; // keep only the string id virtual
        return ret;
      },
    },
  },
);

bookSchema.index({ author: 1, title: 1 });
bookSchema.index({ title: 'text', author: 'text' });

export const Book = mongoose.model('Book', bookSchema);

What each part does:

  • Types are String, Number, Date, Boolean, Buffer, ObjectId, Mixed, Array, Map, UUID, Decimal128, BigInt and nested subdocuments. An array of a type ([String]) is an array path; an array of schemas is a document array. Mixed disables casting and change tracking for that path, so it is a last resort.

  • Built-in validators: required, min, max, minlength, maxlength, enum and match. Each accepts a message in a two-element array or an object. unique is not a validator but an index option, see Unique indexes and duplicate keys.

  • Setters such as trim, lowercase and uppercase run when a value is assigned, before validation.

  • timestamps: true adds createdAt and updatedAt and keeps them current for save(), updateOne(), findOneAndUpdate() and the other update methods. Pass an object ({ createdAt: 'created', updatedAt: false }) to rename or disable either path.

  • id is a virtual string getter for _id, enabled by default. The toJSON option above controls what res.json(book) serializes: virtuals included, the internal __v version key removed, and _id replaced by id.

  • Declare a schema once and compile it with mongoose.model() once, in a module that the rest of the application imports. Calling mongoose.model('Book', schema) twice with the same name throws OverwriteModelError, which commonly appears under hot reload; guard with mongoose.models.Book ?? mongoose.model('Book', bookSchema) where that happens.

The pluralised collection name

Mongoose derives the collection name from the model name: it lowercases it and pluralises it, so the model Book is stored in the collection books, Person in people and Category in categories. The surprise is the reverse direction. A collection that already exists as book or Books is not found and Mongoose silently creates an empty books collection next to it. The model name is a string argument, so a typo creates a different collection with no error. Pass the collection name as the third argument of mongoose.model() or as the schema option collection when it must match an existing collection:

export const Book = mongoose.model('Book', bookSchema, 'books');
// or: new Schema({ ... }, { collection: 'books' })

Strict mode

By default Mongoose is strict about writes: a path that is not in the schema is dropped from a document before it is saved. This is also the first line of defence against mass assignment (a client sending isAdmin: true in the body), but it protects only the paths that the schema does not have. Every field that exists in the schema can still be overwritten by whatever the client sends, so the route handler must pick the fields explicitly (see Validation & Input Handling). In queries, strictQuery decides what happens to filter paths that are not in the schema. It was true in Mongoose 6, false in Mongoose 7 and later, so a filter taken straight from req.query is passed to MongoDB untouched. Never pass req.query or req.body as a filter: a value such as { "$gt": "" } parsed by qs or by express.json() becomes a query operator (NoSQL injection). Build the filter from known keys and cast them yourself; Mongoose also offers the sanitizeFilter option (globally with mongoose.set('sanitizeFilter', true)) which wraps every nested $ operator in $eq.

Validation

Validation runs automatically before save() and create(). It does not run by default for the update family (updateOne, findOneAndUpdate, findByIdAndUpdate), because those send an update operator to the server without building a document. Turn it on with runValidators: true, and know its limits: update validators run only for the paths in the update, they apply required only when the update unsets the path, and a validator that uses this sees the query, not a document. When a rule involves several fields, load the document, assign and call save().

A custom validator is a function, optionally asynchronous, that returns a boolean or throws:

// A custom, asynchronous validator for a subdocument-free rule.
const authorSchema = new Schema({
  name: { type: String, required: true },
  email: {
    type: String,
    required: true,
    lowercase: true,
    validate: {
      validator: (value) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value),
      message: (props) => `${props.value} is not a valid email address`,
    },
  },
});

A failed validation rejects with a ValidationError whose errors property maps each path to a ValidatorError with message, kind (required, min, enum, user defined and so on), path and value. The error handler in Mapping Mongoose errors to HTTP responses turns it into a 422 response listing the fields. Mongoose validation protects the data store, not the HTTP boundary: validate and coerce the request with a schema validator first, so that the client gets a precise error and only well-formed values reach the model.

CRUD routes with async handlers

All Mongoose query methods return promises, so a handler is an async function and Express 5 forwards any rejection to the error handlers. The router below implements the usual five operations for books.

// routes/books.mjs
import { Router } from 'express';
import mongoose from 'mongoose';
import { Book } from '../models/book.mjs';

export const booksRouter = Router();

// Allow-list the writable fields instead of passing req.body to the model.
function pickBookFields(body = {}) {
  const { title, author, isbn, price, genres, status, publishedAt } = body;
  const fields = { title, author, isbn, price, genres, status, publishedAt };
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
}

// Reject malformed ids with 400 before they reach a query (see the CastError section).
booksRouter.param('id', (req, res, next, id) => {
  if (!mongoose.isObjectIdOrHexString(id)) { // exactly 24 hexadecimal characters
    return res.status(400).json({ error: 'Invalid book id' });
  }
  next();
});

// Create
booksRouter.post('/', async (req, res) => {
  const book = await Book.create(pickBookFields(req.body));
  res.status(201).location(`${req.baseUrl}/${book.id}`).json(book);
});

// Read one
booksRouter.get('/:id', async (req, res) => {
  const book = await Book.findById(req.params.id);
  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }
  res.json(book);
});

// Replace or update selected fields
booksRouter.patch('/:id', async (req, res) => {
  const book = await Book.findByIdAndUpdate(req.params.id, { $set: pickBookFields(req.body) }, {
    returnDocument: 'after', // return the updated document
    runValidators: true,     // run schema validators on the updated paths
  });
  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }
  res.json(book);
});

// Delete
booksRouter.delete('/:id', async (req, res) => {
  const book = await Book.findByIdAndDelete(req.params.id);
  if (!book) {
    return res.status(404).json({ error: 'Book not found' });
  }
  res.status(204).end();
});

Points that the code relies on:

  • Model.create(object) validates and inserts; new Book(object).save() is the long form, useful when the document is modified between construction and saving. Model.insertMany([...]) validates every item and inserts in one round trip, and by default stops at the first error unless ordered: false is passed.

  • findById(id) is findOne({ _id: id }). It resolves null when nothing matches, so the 404 check is the handler’s job. Mongoose casts the string id to an ObjectId; a malformed string throws a CastError (next section).

  • findByIdAndUpdate() and findOneAndUpdate() return the document before the update by default. Pass returnDocument: 'after' or the older, equivalent new: true. With $set, only the listed paths change, which is the right semantics for PATCH; for PUT, which replaces the resource, use replaceOne() or findOneAndReplace().

  • Passing a plain object to an update without an operator is treated as $set by Mongoose, so both forms work; spell out $set, $inc, $push and $pull when the operator matters.

  • deleteOne(), deleteMany() and findByIdAndDelete() replace the removed remove() family, see What changed recently.

  • A handler that sends no body after a successful delete answers 204 with res.status(204).end().

  • Every method accepts options in an object; none accepts a callback any more.

The router is mounted in app.mjs, after the body parser, with the error handler last:

// app.mjs
import express from 'express';
import { booksRouter } from './routes/books.mjs';
import { storesRouter } from './routes/stores.mjs';
import { reportsRouter } from './routes/reports.mjs';
import { mongooseErrorHandler } from './middleware/mongoose-errors.mjs';

export const app = express();

app.use(express.json({ limit: '100kb' }));
app.use('/api/books', booksRouter);
app.use('/api/stores', storesRouter);
app.use('/api/reports', reportsRouter);

app.use((req, res) => {
  res.status(404).json({ error: 'Not found' });
});
app.use(mongooseErrorHandler);

Listing, filtering and pagination

A list endpoint should never return the whole collection. The simplest strategy is offset pagination with skip and limit; it is easy and enough for small collections, but its cost grows with the offset because MongoDB must walk the skipped documents. For large collections use keyset pagination, which filters on the last seen sort key and uses an index.

booksRouter.get('/', async (req, res) => {
  const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1);
  const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 20, 1), 100);

  const filter = {};
  if (typeof req.query.author === 'string') {
    filter.author = req.query.author; // a string, so no operator object can sneak in
  }
  if (typeof req.query.status === 'string') {
    filter.status = req.query.status;
  }

  const [items, total] = await Promise.all([
    Book.find(filter)
      .sort({ createdAt: -1, _id: -1 }) // a unique tiebreaker keeps pages stable
      .skip((page - 1) * limit)
      .limit(limit)
      .lean(),
    Book.countDocuments(filter),
  ]);

  res.json({ items, page, limit, total, pages: Math.ceil(total / limit) });
});

The typeof checks matter: Express 5 parses the query string with the simple parser by default (Node.js querystring), so ?author[$ne]=x stays a flat key and req.query.author is a string or an array of strings; with the extended parser (qs) the same request yields an object, which Mongoose would pass on as an operator. Checking the type works with both. Counting is done with countDocuments(filter); the old Model.count() was removed in Mongoose 8.

lean() and projections

A normal query returns full Mongoose documents: change tracking, getters, virtuals, save() and so on. For a read-only response that is wasted work. lean() returns plain JavaScript objects straight from the driver, which is considerably faster and uses less memory in list endpoints. The trade-offs are that the result has no virtuals (so no id), no getters and no save(), toJSON options are not applied, and values are not cast by getters. A lean result is already serializable, but the _id is still an ObjectId that serializes to a string. select('title price') limits the returned fields, select('-isbn') excludes one, and a projection that matches an index (a covered query) never needs to read the documents at all.

ObjectId validation and CastError

A path of type ObjectId accepts a 24-character hexadecimal string or an ObjectId instance. Anything else makes the query throw a CastError (Cast to ObjectId failed for value "abc" (type string) at path "_id" for model "Book"), which would reach Express as a generic 500. A malformed id is the client’s mistake, so two layers handle it:

  • a cheap check at the edge: mongoose.isObjectIdOrHexString(id), which for a string accepts exactly 24 hexadecimal characters, as the router.param('id') callback in the CRUD example does. Avoid mongoose.isValidObjectId(id) and mongoose.Types.ObjectId.isValid(id) for this: neither is stricter than the other, and both accept any 12-character string (and isValidObjectId any number), so a 12-character title is "valid" and casts to bytes.

  • a central error handler that maps any remaining CastError to 400 (below), so that a query built from a body field cannot leak a 500.

Since Mongoose 6 the ObjectId class is the one exported by the driver, and since Mongoose 8 calling it without new (mongoose.Types.ObjectId('...')) throws; write new mongoose.Types.ObjectId(id).

Unique indexes and duplicate keys

unique: true on a path creates a unique index; it is not a validator. A second insert with the same value is rejected by the server with an error whose code is 11000 (MongoServerError: E11000 duplicate key error collection: ...). Two consequences follow. First, uniqueness is guaranteed only once the index exists (see Indexes); with autoIndex disabled in production, you must build it as a deployment step. Second, checking "does this ISBN exist?" before inserting is a race: two requests can both pass the check. Let the database enforce the rule and translate the error: error.code === 11000 becomes 409 Conflict, and error.keyValue names the offending field and value.

Mapping Mongoose errors to HTTP responses

The three error families from Mongoose and the driver need explicit mapping, in one error handler, so that every route benefits. The handler below follows the rules in Error Handling: four parameters, a headersSent check, and a pass-through for everything it does not recognise.

// middleware/mongoose-errors.mjs
import mongoose from 'mongoose';

export function mongooseErrorHandler(err, req, res, next) {
  if (res.headersSent) {
    return next(err);
  }

  if (err instanceof mongoose.Error.ValidationError) {
    const details = Object.fromEntries(
      Object.entries(err.errors).map(([path, problem]) => [path, problem.message]),
    );
    return res.status(422).json({ error: 'Validation failed', details });
  }

  if (err instanceof mongoose.Error.CastError) {
    return res.status(400).json({ error: `Invalid value for ${err.path}` });
  }

  if (err?.code === 11000) {
    const field = Object.keys(err.keyValue ?? {})[0] ?? 'field';
    return res.status(409).json({ error: `Duplicate value for ${field}` });
  }

  if (err instanceof mongoose.Error.DocumentNotFoundError) {
    return res.status(404).json({ error: 'Not found' });
  }

  if (err?.type === 'entity.parse.failed') {
    return res.status(400).json({ error: 'Malformed JSON body' });
  }

  console.error(err); // log the details, never send them to the client
  res.status(500).json({ error: 'Internal server error' });
}

The 500 branch deliberately returns a fixed message: Mongoose and driver messages can include collection names, field values and server addresses. mongoose.Error.DocumentNotFoundError is thrown by orFail(), which turns a null result into an error and removes the explicit if (!book) checks: await Book.findById(id).orFail().

Indexes

An index is declared on the schema (index: true on a path, schema.index({ ... }) for compound, text and geospatial indexes) and built by Model.init(). By default Mongoose calls createIndex for every declared index when the model is compiled and the connection opens (autoIndex: true). That is convenient in development and risky in production: building an index on a large collection takes time and load, the application keeps accepting requests while the build runs, and a unique index fails to build if duplicates already exist. The common policy is autoIndex on in development and tests, off in production, with indexes created by an explicit migration or by calling await Book.syncIndexes() from a deploy job. syncIndexes() also drops indexes that are no longer in the schema, so use it deliberately.

  • Order of keys in a compound index matters: { author: 1, title: 1 } serves queries by author, and by author and title, but not by title alone. Put equality fields first, then sort fields.

  • A text index ({ title: 'text' }) supports $text search; each collection can have one.

  • ensureIndex (used in 2016-era code) was removed from the MongoDB drivers and servers; Mongoose uses createIndex.

  • explain() shows whether a query used an index: await Book.find(filter).explain('executionStats').

  • A TTL index ({ expires: 3600 } on a Date path) lets MongoDB delete expired documents, such as short-lived tokens.

Geospatial queries: 2dsphere and $near

MongoDB stores locations as GeoJSON and indexes them with a 2dsphere index, which supports queries on a sphere: documents near a point, inside a polygon, or intersecting a shape. A GeoJSON point is an object with a type and coordinates in the order longitude, latitude (the opposite of the order most map UIs print). Getting that order wrong is the commonest geospatial bug, and the symptom is a query that returns nothing or errors with "longitude/latitude is out of bounds".

// models/store.mjs
import mongoose from 'mongoose';

const { Schema } = mongoose;

const pointSchema = new Schema(
  {
    type: { type: String, enum: ['Point'], required: true },
    coordinates: {
      type: [Number], // [longitude, latitude]
      required: true,
      validate: {
        validator: (value) =>
          value.length === 2 &&
          value[0] >= -180 && value[0] <= 180 &&
          value[1] >= -90 && value[1] <= 90,
        message: 'coordinates must be [longitude, latitude] within valid ranges',
      },
    },
  },
  { _id: false },
);

const storeSchema = new Schema(
  {
    name: { type: String, required: true, trim: true },
    address: String,
    location: { type: pointSchema, required: true },
  },
  { timestamps: true },
);

storeSchema.index({ location: '2dsphere' });

export const Store = mongoose.model('Store', storeSchema);
// routes/stores.mjs
import { Router } from 'express';
import { Store } from '../models/store.mjs';

export const storesRouter = Router();

storesRouter.post('/', async (req, res) => {
  const { name, address, longitude, latitude } = req.body;
  const store = await Store.create({
    name,
    address,
    location: { type: 'Point', coordinates: [Number(longitude), Number(latitude)] },
  });
  res.status(201).json(store);
});

// GET /api/stores/nearby?lng=-3.70&lat=40.41&maxKm=5&limit=10
storesRouter.get('/nearby', async (req, res) => {
  const longitude = Number(req.query.lng);
  const latitude = Number(req.query.lat);
  const maxKm = Math.min(Number(req.query.maxKm ?? 5), 50);
  const limit = Math.min(Number.parseInt(req.query.limit, 10) || 10, 50);

  if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || !Number.isFinite(maxKm)) {
    return res.status(400).json({ error: 'lng, lat and maxKm must be numbers' });
  }

  const stores = await Store.find({
    location: {
      $near: {
        $geometry: { type: 'Point', coordinates: [longitude, latitude] },
        $maxDistance: maxKm * 1000, // metres for GeoJSON points
      },
    },
  })
    .limit(limit)
    .lean();

  res.json(stores); // $near returns the closest first
});

Source: MongoDB: $near

How the geospatial query behaves:

  • $near (and $nearSphere) requires a 2dsphere index on the queried path; without it the query fails. It returns documents sorted by distance, nearest first, and ignores any sort() you add, which is why the result needs no extra ordering. $maxDistance and $minDistance are in metres for GeoJSON points (and in radians for legacy coordinate pairs on a 2d index).

  • $near is not allowed inside a $match stage (use $geoNear) or in countDocuments(). For counting, use $geoWithin with $centerSphere (radius in radians, the distance divided by 6378.1 kilometres), which does not need to sort.

  • $geoWithin with a $geometry polygon returns the documents inside an area, without sorting; $geoIntersects finds those whose geometry crosses a shape.

  • To get the distance itself in the response, use the $geoNear aggregation stage, shown in the next section.

  • All coordinates must be valid: longitude from -180 to 180 and latitude from -90 to 90. Validate them at the route (a schema validator, see Validation & Input Handling) as well as in the model.

  • Each document can have more than one location-bearing path, and a collection can have several 2dsphere indexes, but $near must name the one it uses.

Aggregation endpoints

An aggregation pipeline is an array of stages that transform a stream of documents: $match filters, $group accumulates, $sort orders, $project reshapes, $lookup joins another collection, $unwind flattens arrays, $facet runs several pipelines in parallel and $geoNear finds documents near a point and adds the distance. MongoDB executes the whole pipeline on the server and returns only the result, which makes it the right tool for report endpoints that would otherwise load thousands of documents into the Node.js process.

// routes/reports.mjs
import { Router } from 'express';
import { Book } from '../models/book.mjs';
import { Store } from '../models/store.mjs';

export const reportsRouter = Router();

// GET /api/reports/books-by-author?minBooks=2
reportsRouter.get('/books-by-author', async (req, res) => {
  const minBooks = Math.max(Number.parseInt(req.query.minBooks, 10) || 1, 1);

  const rows = await Book.aggregate([
    { $match: { status: 'published' } },
    {
      $group: {
        _id: '$author',
        books: { $sum: 1 },
        averagePrice: { $avg: '$price' },
        totalValue: { $sum: '$price' },
        genres: { $addToSet: '$genres' },
      },
    },
    { $match: { books: { $gte: minBooks } } },
    { $sort: { books: -1, _id: 1 } },
    { $limit: 50 },
    {
      $project: {
        _id: 0,
        author: '$_id',
        books: 1,
        averagePrice: { $round: ['$averagePrice', 2] },
        totalValue: { $round: ['$totalValue', 2] },
      },
    },
  ]);

  res.json(rows);
});

// GET /api/reports/stores-with-distance?lng=-3.70&lat=40.41
reportsRouter.get('/stores-with-distance', async (req, res) => {
  const longitude = Number(req.query.lng);
  const latitude = Number(req.query.lat);
  if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
    return res.status(400).json({ error: 'lng and lat must be numbers' });
  }

  const rows = await Store.aggregate([
    {
      $geoNear: {
        near: { type: 'Point', coordinates: [longitude, latitude] },
        distanceField: 'distanceMeters',
        maxDistance: 20_000,
        spherical: true,
      },
    },
    { $project: { name: 1, address: 1, distanceMeters: { $round: ['$distanceMeters', 0] } } },
    { $limit: 10 },
  ]);

  res.json(rows);
});

Things that distinguish aggregation from ordinary queries in Mongoose:

  • Model.aggregate() does not cast values with the schema. A string '42' stays a string and an _id given as a string does not match an ObjectId. Convert explicitly, using new mongoose.Types.ObjectId(id) for _id values in a $match, and Number() for numbers taken from the request.

  • The result is plain objects, not documents, as with lean(). There are no virtuals and no id.

  • $geoNear must be the first stage of the pipeline and requires a 2dsphere index (or a 2d index, but not both on the collection). It is the stage to use when the response needs the distance; $near is the query operator for the rest.

  • Put $match and $sort first so they can use indexes, and $limit early whenever the result size allows, because a stage that has to sort or group more than 100 MB of data fails unless allowDiskUse is set.

  • Never build a pipeline stage from request input as an object (for example { $match: req.query }): the client could supply operators such as $where or $function. Select known fields, as in the examples.

  • A pipeline is just data, so a reporting route can store a few named pipelines in a module and choose among them with a validated query parameter; this keeps routes short and testable.

  • Use explain and an index on the $match fields to keep a frequently called report fast, and consider caching the result with an HTTP validator or a short TTL (see Content Negotiation & HTTP Caching).

Transactions and hooks

Operations on a single document are atomic in MongoDB, so most Express endpoints need no transaction. When several documents must change together (for instance an order and its stock), use a multi-document transaction, which requires a replica set (a MongoDB Atlas cluster or a local replica set; a standalone server does not support it). In Mongoose, connection.transaction(async (session) => { ... }) runs the callback inside a transaction, retries it on transient errors and commits or aborts it, and every query in it must be passed { session }:

import mongoose from 'mongoose';
import { Book } from './models/book.mjs';

export async function archiveAuthor(author) {
  return mongoose.connection.transaction(async (session) => {
    const result = await Book.updateMany({ author }, { $set: { status: 'archived' } }, { session });
    // ... other writes in the same transaction receive the same session ...
    return result.modifiedCount;
  });
}

Middleware hooks (schema.pre('save', ...), schema.post('save', ...)) attach logic such as hashing a password or maintaining a derived field to the document lifecycle. Document hooks run for save() and create(); query hooks (pre('findOneAndUpdate')) run for query methods, and a hook for save does not run for updateOne. Write hooks as async functions that return normally or throw. The next callback style of old tutorials is deprecated in Mongoose 8 for most uses and was removed from pre hooks in Mongoose 9 according to its migration guide, so use async hooks in new code.

Testing the data layer

Route tests can run against a real MongoDB in a container or against mongodb-memory-server, which downloads and starts a throwaway mongod process. Create the server in a before hook, connect Mongoose to its URI, and clear the collections between tests; a complete example with supertest and node:test is in Testing. Because the application is exported from app.mjs without listening, a test connects Mongoose and imports app only.

MongoDB Atlas instead of mLab

The books use hosted services that no longer exist (mLab, previously MongoLab, and MongoHQ, later Compose). The equivalent today is MongoDB Atlas, the official managed service, which has a free tier suitable for learning. The steps are the same wherever the database is hosted:

  1. Create a cluster and a database user with a strong, generated password stored in a secrets manager or an ignored .env file.

  2. Restrict network access to the addresses of the application; do not leave the "allow access from anywhere" rule on for a real deployment.

  3. Copy the mongodb+srv:// connection string, which resolves the hosts through DNS, and put it in MONGODB_URI. The string always has placeholders in documentation (mongodb+srv://<user>:<password>@<cluster-host>/<database>); the real one never enters version control.

  4. Give the database user only the roles the application needs on its own database, not the Atlas admin role.

  5. Enable backups and alerts before the first real user.

What changed recently

Version Change What to do

Mongoose 9 (current latest)

Requires Node.js 20.19 or later; uses the MongoDB driver 7.x. Several long-deprecated APIs were removed, and pre hooks no longer support the next callback.

Read the official migration guide before upgrading from 8; use async hooks.

Mongoose 8

Removed Model.remove(), Document.prototype.remove(), findOneAndRemove(), findByIdAndRemove() and Model.count(). The rawResult option was deprecated in favour of includeResultMetadata. The ObjectId constructor requires new. Driver 6 removed the connection options useNewUrlParser and useUnifiedTopology.

Use deleteOne(), deleteMany(), findOneAndDelete(), findByIdAndDelete() and countDocuments(); call new on ObjectId.

Mongoose 7

Removed callbacks from every function that returned a promise; strictQuery default changed to false.

Use async/await; build filters from known keys.

Mongoose 6

useCreateIndex, useFindAndModify and similar options were removed (always on); the default strictQuery became true; ObjectId is the driver’s class.

Delete the options from connect().

Mongoose 5

useMongoClient disappeared with Mongoose 5; connect() returns a promise.

Use mongoose.connect(uri) and await.

Where the books are outdated

The consulted books build their data layer on versions between Mongoose 3.8 and 8, with Express 4. The concepts are all still valid; the code needs these updates:

Concept in the books Why it no longer works Current approach

Callbacks: Model.find(query, function (err, docs) { ... }), save(callback), exec(callback)

Callbacks were removed in Mongoose 7. A callback passed to a query is rejected, and code that relies on it hangs or throws.

const docs = await Model.find(query) inside an async handler; Express 5 forwards the rejection.

Model.remove(), doc.remove(), findOneAndRemove(), findByIdAndRemove()

Removed in Mongoose 8.

deleteOne(), deleteMany(), findOneAndDelete(), findByIdAndDelete().

Model.count()

Removed in Mongoose 8.

countDocuments() for a filtered count, estimatedDocumentCount() for the whole collection.

mongoose.connect(uri, { useMongoClient: true }), useNewUrlParser, useUnifiedTopology

useMongoClient ended in Mongoose 5; the other two options are no-ops since driver 4 and rejected or ignored later.

await mongoose.connect(uri), before app.listen().

Setting mongoose.Promise = global.Promise (or bluebird)

Native promises are the only option.

Delete the line.

findOneAndUpdate returning the old document unless { new: true }

Behaviour unchanged, but the option is now more readable as returnDocument: 'after'.

Pass returnDocument: 'after' and runValidators: true.

ensureIndex (2016-era code and drivers)

Removed from MongoDB drivers and servers 5.0 and later.

createIndex through schema indexes; syncIndexes() in deployment jobs.

require('connect-mongo')(session) for session storage

The factory signature was replaced.

MongoStore.create({ mongoUrl }); see Cookies & Sessions.

A free mLab or MongoHQ database

Both services ended.

MongoDB Atlas, or a container, with the connection string in an environment variable.

new Model(req.body).save() or Model.create(req.body)

Mass assignment: the client can set role, isAdmin or author.

Pick the fields explicitly; validate the body first.

Model.find(req.query)

NoSQL injection through operator objects when the query parser yields objects.

Build the filter from known keys and check the types; consider sanitizeFilter.

Opening the connection inside a route or per request

Creates a pool per call and leaks connections.

Connect once at startup; close once at shutdown.

Comparing ids with ===

Two ObjectId instances are never ===.

Compare with a.equals(b) or String(a) === String(b).

Common mistakes

Symptom Cause Fix

A request hangs for ten seconds and then fails with a buffering timeout.

The model was used before the connection was established, or the connection never succeeded.

Connect before app.listen(), set serverSelectionTimeoutMS, and log the connection error.

The data is saved in a collection that is empty in the database tool.

The pluralised, lower-cased name differs from the real collection.

Pass the collection name as the third argument of mongoose.model().

An update returns the old document.

findByIdAndUpdate returns the original by default.

Pass returnDocument: 'after'.

An update saves invalid data.

Update methods skip validators unless asked.

Pass runValidators: true, or load, assign and save().

500 for /api/books/abc.

A CastError reached the default handler.

Validate the id at the edge and map CastError to 400.

E11000 duplicate key in the log and 500 to the client.

A unique index rejected the write.

Map the error code to 409.

$near returns nothing.

Coordinates are swapped, or the 2dsphere index is missing.

Use [longitude, latitude] and check Store.collection.getIndexes().

OverwriteModelError on restart.

The model was compiled twice.

Export the model from one module; guard with mongoose.models under hot reload.

An aggregation $match on _id finds nothing.

The string id was not cast.

Use new mongoose.Types.ObjectId(id).

A lean result has no id.

lean() skips virtuals.

Use _id, or add lean({ virtuals: true }) with the mongoose-lean-virtuals plugin.