Mongoose Queries & Population
With schemas defined, day-to-day work is querying. Mongoose's query API is a chainable, promise-based builder layered over the driver's — plus population, which resolves referenced documents in a join-like step. This page covers the query builder, filtering operators, projection and pagination, population, lean queries for performance, and the common pitfalls.
The chainable query builder
JS
// A Query is built up by chaining, then awaited (or .exec()):
const users = await User.find({ role: 'user' })
.where('age').gte(18).lte(65) // age between 18 and 65
.select('name email age') // projection: only these fields
.sort('-createdAt') // newest first ('-' = descending)
.limit(20)
.skip(40) // pagination offset
.exec() // run it (await alone works too)A query isn't run until you await it
`User.find(...)` returns a **Query** object you keep refining; nothing hits the database until you `await` it or call `.exec()`. This lazy building is why you can compose conditions across lines. Both `await query` and `await query.exec()` work — `.exec()` gives a cleaner stack trace on errors, so many prefer it.
Filtering operators
JS
await Product.find({
price: { $gte: 10, $lte: 100 }, // range
category: { $in: ['book', 'toy'] }, // membership
tags: { $all: ['sale', 'new'] }, // array contains all
name: { $regex: 'pro', $options: 'i' },// case-insensitive contains
discontinued: { $ne: true }, // not equal
$or: [{ featured: true }, { rating: { $gte: 4.5 } }],
})Avoid unanchored `$regex` on large collections — it can't use an index
A regex like `{ $regex: 'pro' }` (unanchored) forces a collection scan — it can't use a btree index, so it's slow at scale. Anchored prefixes (`/^pro/`) *can* use an index. For real search, prefer a **text index** (`$text`) or a dedicated search engine over regex. Building filters from user input also risks [NoSQL injection](/nodejs/mongodb-driver) — validate types first.
Projection — fetch only what you need
JS
// Include specific fields:
await User.find().select('name email') // + _id by default
// Exclude fields (e.g. never ship the password hash):
await User.find().select('-password -__v')
// Schema-level: hide a field from ALL queries by default:
// password: { type: String, select: false }Project narrowly — and never leak secrets
Returning only needed fields cuts payload size and memory. Critically, exclude sensitive fields (`-password`) from every response — or set `select: false` on the schema path so it's omitted by default and only included when you explicitly ask. Combine with a `toJSON` transform ([schemas](/nodejs/mongoose-schemas)) for defense in depth.
Pagination
JS
const page = Math.max(1, Number(req.query.page) || 1)
const limit = Math.min(100, Number(req.query.limit) || 20) // clamp!
const [data, total] = await Promise.all([
Post.find().sort('-createdAt').skip((page - 1) * limit).limit(limit),
Post.countDocuments(),
])
res.json({ data, page, totalPages: Math.ceil(total / limit) })`skip()` degrades on deep pages — same offset problem as SQL
`.skip(n)` makes MongoDB walk and discard `n` documents, so deep pages get progressively slower — the [offset-pagination problem](/nodejs/pagination). For large or live collections, paginate by a **cursor** (`find({ _id: { $gt: lastId } })`) instead. And always **clamp `limit`** — an unbounded page size is a DoS vector regardless of database.
Population — resolving references
JS
// post.author stores an ObjectId; populate() swaps in the User document:
const post = await Post.findById(id)
.populate('author', 'name email') // only pull these author fields
.populate({
path: 'comments',
populate: { path: 'user', select: 'name' }, // nested populate
})
// post.author is now the full (partial) User, not just an id:
post.author.name{
"title": "Hello",
"author": { "_id": "...", "name": "Ada", "email": "ada@x.com" },
"comments": [ { "text": "Nice!", "user": { "name": "Bob" } } ]
}`populate` is a SEPARATE query, not a real join — beware N+1
MongoDB isn't relational: `populate` issues *additional* queries to fetch the referenced documents — it's not a database-level join. Populating inside a loop (fetch posts, then populate each author one-by-one) creates the classic **N+1 query** problem. Populate in the same query for a batch, project only the fields you need, and for read-heavy hot paths consider *denormalizing* (storing `authorName` on the post) instead. Over-populating deep trees is a common performance killer.
Lean queries — skip the document overhead
JS
// Default: returns full Mongoose Documents (getters, virtuals, .save()): const docs = await User.find() // .lean(): returns PLAIN JS objects — much faster, but no document methods: const plain = await User.find().lean() // read-only list endpoints
Use `.lean()` for read-only responses
A Mongoose Document wraps each result with change-tracking, getters, virtuals, and methods — useful when you'll modify and `.save()`, but pure overhead when you just serialize to JSON. `.lean()` returns raw objects, often several times faster and lighter. Use it for list/read endpoints where you won't call document methods. Don't use it when you need `.save()`, virtuals, or hooks on the result.
Handling not-found and cast errors
JS
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id) // CastError if id malformed
if (!user) return res.status(404).json({ error: 'Not found' })
res.json(user)
} catch (err) {
if (err.name === 'CastError') return res.status(400).json({ error: 'Invalid id' })
next(err)
}
})`null` means not-found; `CastError` means malformed id
`findById`/`findOne` resolve to `null` when nothing matches — that's your [404](/nodejs/api-error-responses), not an error. A malformed id (`/users/abc`) throws a `CastError` you should map to `400`. Centralizing these mappings in your [error middleware](/nodejs/error-middleware) (translate `CastError`→400, `ValidationError`→422, E11000→409) keeps every handler clean.
Aggregation for the hard questions
JS
// Grouping/analytics drop to the aggregation pipeline:
const topBuyers = await Order.aggregate([
{ $match: { status: 'paid' } },
{ $group: { _id: '$userId', spent: { $sum: '$amount' } } },
{ $sort: { spent: -1 } },
{ $limit: 5 },
])Aggregation bypasses schema casting — pass real types
The [aggregation pipeline](/nodejs/mongodb-intro) runs closer to the driver and does **not** apply Mongoose schema casting, so you must pass already-correct types (e.g. `new mongoose.Types.ObjectId(id)` in a `$match`, not a string). It's the tool for grouping, joins (`$lookup`), and analytics that `find` can't express — at the cost of more verbose, less "Mongoose-y" syntax.
Next
Switch to the relational world with Postgres: [PostgreSQL with node-postgres](/nodejs/postgresql).