NodeJSMongoose Queries & Population

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).