Querying is the heart of every database-driven application. Every time React displays a list of posts, shows a user’s profile, or searches for content, it triggers a query through your Express API to MongoDB. Understanding how MongoDB queries work — how to write precise filters, select only the fields you need with projections, sort results, and paginate large datasets — directly determines the performance and behaviour of your MERN application. In this lesson you will master every querying technique you will use throughout the series.
Query Methods Overview
| Method | Returns | Use When |
|---|---|---|
Model.find(filter) |
Array of matching documents | Get a list of matching records |
Model.findOne(filter) |
First matching document or null | Get one record by any field |
Model.findById(id) |
Document with that _id or null | Get one record by _id |
Model.countDocuments(filter) |
Integer count | Pagination total, dashboard stats |
Model.exists(filter) |
{ _id } or null |
Check existence without fetching full doc |
Model.distinct(field, filter) |
Array of unique values | Get all unique tags, categories |
Model.find() returns a Mongoose Query object, not a Promise. You can chain methods like .sort(), .limit(), .skip(), and .populate() onto it before awaiting. When you await a Mongoose Query, it executes and returns the result. This is why await Post.find().sort({ createdAt: -1 }).limit(10) works — you are awaiting the chain, not the individual methods..lean() at the end of a query chain when you do not need Mongoose document methods (like .save()) on the result. lean() returns plain JavaScript objects instead of Mongoose documents — they use less memory, are faster to create, and can be directly serialised to JSON. Use it for GET endpoints that only read and return data: await Post.find({ published: true }).lean().await Model.find() without a .limit() on a large collection in a production API. If your posts collection has 100,000 documents and React calls GET /api/posts without pagination, MongoDB will try to return all 100,000 documents to your Express server — overwhelming memory, saturating the network, and timing out the request. Always apply a default limit in every list endpoint.Basic Filtering
// ── Find all published posts ───────────────────────────────────────────────────
const posts = await Post.find({ published: true });
// ── Find by a specific field value ────────────────────────────────────────────
const post = await Post.findOne({ slug: 'getting-started-with-mern' });
// ── Find by _id ───────────────────────────────────────────────────────────────
const post = await Post.findById('64a1f2b3c8e4d5f6a7b8c9d0');
// Shorthand for: Post.findOne({ _id: '64a1f2b3c8e4d5f6a7b8c9d0' })
// ── Find posts by author ───────────────────────────────────────────────────────
const authorPosts = await Post.find({ author: req.user.id });
// ── Find posts with a specific tag ────────────────────────────────────────────
// MongoDB automatically searches inside the tags array
const mernPosts = await Post.find({ tags: 'mern' });
// ── Multiple conditions — AND by default ──────────────────────────────────────
const posts = await Post.find({
published: true,
tags: 'mern',
featured: false,
}); // published AND has tag 'mern' AND not featured
Projections — Select Specific Fields
// Include only specific fields (1 = include)
const posts = await Post.find({ published: true }, { title: 1, slug: 1, createdAt: 1 });
// Returns: [{ _id, title, slug, createdAt }, ...] (_id always included unless excluded)
// Exclude specific fields (0 = exclude)
const posts = await Post.find({}, { body: 0, __v: 0 });
// Returns all fields EXCEPT body and __v
// Exclude _id as well
const slugs = await Post.find({ published: true }, { slug: 1, _id: 0 });
// Returns: [{ slug: 'post-1' }, { slug: 'post-2' }, ...]
// Mongoose .select() syntax (equivalent to projection)
const posts = await Post.find({ published: true })
.select('title slug createdAt author') // space-separated includes
.select('-body -__v'); // minus prefix = exclude
Sorting, Limiting, Skipping
// ── Sort ──────────────────────────────────────────────────────────────────────
const newest = await Post.find({ published: true })
.sort({ createdAt: -1 }); // -1 = descending (newest first)
// 1 = ascending (oldest first)
// Sort by multiple fields
const posts = await Post.find({})
.sort({ featured: -1, createdAt: -1 }); // featured posts first, then newest
// ── Limit ─────────────────────────────────────────────────────────────────────
const latest5 = await Post.find({ published: true })
.sort({ createdAt: -1 })
.limit(5); // return at most 5 documents
// ── Skip + Limit = Pagination ─────────────────────────────────────────────────
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
Post.find({ published: true })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.populate('author', 'name avatar'),
Post.countDocuments({ published: true }),
]);
res.json({
success: true,
data: posts,
total,
page,
pages: Math.ceil(total / limit),
});
Populate — Resolving References
// Without populate — author field is just an ObjectId string
const post = await Post.findById(id);
console.log(post.author); // ObjectId("64a1f2b3c8e4d5f6a7b8c9d1")
// With populate — author field is replaced with the full User document
const post = await Post.findById(id).populate('author');
console.log(post.author.name); // "Jane Smith"
console.log(post.author.avatar); // "https://..."
// Populate with field selection — only fetch specific user fields
const post = await Post.findById(id)
.populate('author', 'name avatar bio'); // only name, avatar, bio
// Populate multiple references
const post = await Post.findById(id)
.populate('author', 'name avatar')
.populate('comments.author', 'name'); // nested populate
// Populate in a list query
const posts = await Post.find({ published: true })
.sort({ createdAt: -1 })
.limit(10)
.populate('author', 'name avatar')
.lean(); // lean + populate = fast read-only query
exists and countDocuments
// Check if a document exists without fetching it
const exists = await Post.exists({ slug: 'getting-started' });
// Returns { _id: ObjectId(...) } if exists, null if not
// Count — for pagination totals and dashboard stats
const total = await Post.countDocuments({ published: true });
const draftCount = await Post.countDocuments({ published: false, author: userId });
// Combine count with find in parallel for efficient pagination
const [posts, total] = await Promise.all([
Post.find(filter).skip(skip).limit(limit),
Post.countDocuments(filter),
]);
Common Mistakes
Mistake 1 — Fetching all documents without a limit
❌ Wrong — no limit on a production collection:
const posts = await Post.find({ published: true }); // could return 100,000 docs
✅ Correct — always apply a sensible default limit:
const posts = await Post.find({ published: true }).limit(10); // ✓
Mistake 2 — Populating unnecessarily on every query
❌ Wrong — populating the full author object for a list endpoint that only shows the post title:
const posts = await Post.find({}).populate('author'); // loads full user doc unnecessarily
✅ Correct — only populate the fields the response actually needs:
const posts = await Post.find({}).populate('author', 'name avatar').lean(); // only name + avatar ✓
Mistake 3 — Using find() when findOne() is the intent
❌ Wrong — returning an array when the API contract says “return one post by slug”:
const posts = await Post.find({ slug: 'my-post' }); // returns array
res.json({ data: posts[0] }); // fragile — relies on array having one element
✅ Correct — use findOne() which returns a single document or null:
const post = await Post.findOne({ slug: 'my-post' }); // null if not found ✓
if (!post) throw new AppError('Post not found', 404);
Quick Reference
| Task | Mongoose Code |
|---|---|
| Find all matching | Model.find({ filter }) |
| Find one | Model.findOne({ filter }) |
| Find by ID | Model.findById(id) |
| Select fields | .select('title slug -body') |
| Sort results | .sort({ createdAt: -1 }) |
| Paginate | .skip((page-1)*limit).limit(limit) |
| Resolve reference | .populate('author', 'name avatar') |
| Fast read-only | .lean() |
| Count results | Model.countDocuments({ filter }) |
| Check existence | Model.exists({ filter }) |