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.
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();
}
Source: Mongoose: Connections
// 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 untilserverSelectionTimeoutMS. After the first successful connection, the driver reconnects automatically after a network failure, so application code does not reconnect by hand. -
useNewUrlParser,useUnifiedTopologyanduseCreateIndex(all seen in older tutorials) do nothing in current versions: the options were removed from the driver and Mongoose ignores or rejects them.useMongoClientdisappeared in Mongoose 5. -
An application normally uses the default connection, created by
mongoose.connect(). Usemongoose.createConnection()only when one process talks to several databases; models are then registered on the connection instead of onmongoose. -
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);
Source: Mongoose: Schemas
What each part does:
-
Types are
String,Number,Date,Boolean,Buffer,ObjectId,Mixed,Array,Map,UUID,Decimal128,BigIntand nested subdocuments. An array of a type ([String]) is an array path; an array of schemas is a document array.Mixeddisables casting and change tracking for that path, so it is a last resort. -
Built-in validators:
required,min,max,minlength,maxlength,enumandmatch. Each accepts a message in a two-element array or an object.uniqueis not a validator but an index option, see Unique indexes and duplicate keys. -
Setters such as
trim,lowercaseanduppercaserun when a value is assigned, before validation. -
timestamps: trueaddscreatedAtandupdatedAtand keeps them current forsave(),updateOne(),findOneAndUpdate()and the other update methods. Pass an object ({ createdAt: 'created', updatedAt: false }) to rename or disable either path. -
idis a virtual string getter for_id, enabled by default. ThetoJSONoption above controls whatres.json(book)serializes: virtuals included, the internal__vversion key removed, and_idreplaced byid. -
Declare a schema once and compile it with
mongoose.model()once, in a module that the rest of the application imports. Callingmongoose.model('Book', schema)twice with the same name throwsOverwriteModelError, which commonly appears under hot reload; guard withmongoose.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' })
Source: Mongoose: Models
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`,
},
},
});
Source: Mongoose: Validation
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();
});
Source: Mongoose: Queries
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 unlessordered: falseis passed. -
findById(id)isfindOne({ _id: id }). It resolvesnullwhen nothing matches, so the 404 check is the handler’s job. Mongoose casts the stringidto anObjectId; a malformed string throws aCastError(next section). -
findByIdAndUpdate()andfindOneAndUpdate()return the document before the update by default. PassreturnDocument: 'after'or the older, equivalentnew: true. With$set, only the listed paths change, which is the right semantics forPATCH; forPUT, which replaces the resource, usereplaceOne()orfindOneAndReplace(). -
Passing a plain object to an update without an operator is treated as
$setby Mongoose, so both forms work; spell out$set,$inc,$pushand$pullwhen the operator matters. -
deleteOne(),deleteMany()andfindByIdAndDelete()replace the removedremove()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);
Source: Express: Routing
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) });
});
Source: Mongoose: Queries
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 therouter.param('id')callback in the CRUD example does. Avoidmongoose.isValidObjectId(id)andmongoose.Types.ObjectId.isValid(id)for this: neither is stricter than the other, and both accept any 12-character string (andisValidObjectIdany number), so a 12-character title is "valid" and casts to bytes. -
a central error handler that maps any remaining
CastErrorto 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' });
}
Source: Mongoose: Error API
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$textsearch; each collection can have one. -
ensureIndex(used in 2016-era code) was removed from the MongoDB drivers and servers; Mongoose usescreateIndex. -
explain()shows whether a query used an index:await Book.find(filter).explain('executionStats'). -
A TTL index (
{ expires: 3600 }on aDatepath) lets MongoDB delete expired documents, such as short-lived tokens.
Source: Mongoose: Indexes
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);
Source: MongoDB: 2dsphere indexes
// 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 a2dsphereindex on the queried path; without it the query fails. It returns documents sorted by distance, nearest first, and ignores anysort()you add, which is why the result needs no extra ordering.$maxDistanceand$minDistanceare in metres for GeoJSON points (and in radians for legacy coordinate pairs on a2dindex). -
$nearis not allowed inside a$matchstage (use$geoNear) or incountDocuments(). For counting, use$geoWithinwith$centerSphere(radius in radians, the distance divided by 6378.1 kilometres), which does not need to sort. -
$geoWithinwith a$geometrypolygon returns the documents inside an area, without sorting;$geoIntersectsfinds those whose geometry crosses a shape. -
To get the distance itself in the response, use the
$geoNearaggregation 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
2dsphereindexes, but$nearmust 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);
});
Source: MongoDB: Aggregation
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_idgiven as a string does not match anObjectId. Convert explicitly, usingnew mongoose.Types.ObjectId(id)for_idvalues in a$match, andNumber()for numbers taken from the request. -
The result is plain objects, not documents, as with
lean(). There are no virtuals and noid. -
$geoNearmust be the first stage of the pipeline and requires a2dsphereindex (or a2dindex, but not both on the collection). It is the stage to use when the response needs the distance;$nearis the query operator for the rest. -
Put
$matchand$sortfirst so they can use indexes, and$limitearly whenever the result size allows, because a stage that has to sort or group more than 100 MB of data fails unlessallowDiskUseis set. -
Never build a pipeline stage from request input as an object (for example
{ $match: req.query }): the client could supply operators such as$whereor$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
explainand an index on the$matchfields 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;
});
}
Source: Mongoose: Transactions
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:
-
Create a cluster and a database user with a strong, generated password stored in a secrets manager or an ignored
.envfile. -
Restrict network access to the addresses of the application; do not leave the "allow access from anywhere" rule on for a real deployment.
-
Copy the
mongodb+srv://connection string, which resolves the hosts through DNS, and put it inMONGODB_URI. The string always has placeholders in documentation (mongodb+srv://<user>:<password>@<cluster-host>/<database>); the real one never enters version control. -
Give the database user only the roles the application needs on its own database, not the Atlas admin role.
-
Enable backups and alerts before the first real user.
Source: MongoDB Atlas: Get started
What changed recently
| Version | Change | What to do |
|---|---|---|
Mongoose 9 (current |
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 |
Read the official migration guide before upgrading from 8; use |
Mongoose 8 |
Removed |
Use |
Mongoose 7 |
Removed callbacks from every function that returned a promise; |
Use |
Mongoose 6 |
|
Delete the options from |
Mongoose 5 |
|
Use |
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: |
Callbacks were removed in Mongoose 7. A callback passed to a query is rejected, and code that relies on it hangs or throws. |
|
|
Removed in Mongoose 8. |
|
|
Removed in Mongoose 8. |
|
|
|
|
Setting |
Native promises are the only option. |
Delete the line. |
|
Behaviour unchanged, but the option is now more readable as |
Pass |
|
Removed from MongoDB drivers and servers 5.0 and later. |
|
|
The factory signature was replaced. |
|
A free mLab or MongoHQ database |
Both services ended. |
MongoDB Atlas, or a container, with the connection string in an environment variable. |
|
Mass assignment: the client can set |
Pick the fields explicitly; validate the body first. |
|
NoSQL injection through operator objects when the query parser yields objects. |
Build the filter from known keys and check the types; consider |
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 |
Compare with |
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 |
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 |
An update returns the old document. |
|
Pass |
An update saves invalid data. |
Update methods skip validators unless asked. |
Pass |
|
A |
Validate the id at the edge and map |
|
A unique index rejected the write. |
Map the error code to 409. |
|
Coordinates are swapped, or the |
Use |
|
The model was compiled twice. |
Export the model from one module; guard with |
An aggregation |
The string id was not cast. |
Use |
A lean result has no |
|
Use |
References
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Database Integration, Validation & Input Handling, Error Handling, Testing