Querying Documents — find, Filters and Projections

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
Note: 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.
Tip: Use .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().
Warning: Never call 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 })

🧠 Test Yourself

You call await Post.find({ published: true }).populate('author').sort({ createdAt: -1 }) but Mongoose throws an error saying populate() must be called before sort(). How do you fix the chain?