Counting Documents
Sooner or later every application needs to answer “how many?” — how many orders shipped today, how many users are active, how many documents match a filter before you decide whether to page through them. MongoDB gives you three different tools for this job, and they are not interchangeable: one is precise but can be slow on huge collections, one is blazing fast but can’t filter, and one is deprecated and best avoided entirely. Understanding the difference — and how each one actually executes under the hood — is the difference between a dashboard that scales and one that grinds a production cluster to a halt.
Overview / How Counting Works in MongoDB
Unlike SQL’s single COUNT(*), MongoDB exposes counting through a few distinct code paths because “count” means different things depending on whether you need an exact, filtered number right now, or just a fast approximate total.
db.collection.countDocuments(filter, options) is the modern, accurate way to count. It does not maintain its own counter — internally, mongosh and the drivers rewrite it into an aggregation pipeline: your filter becomes a $match stage, followed by a $group stage that sums 1 for every document that survives the match (roughly { $group: { _id: null, n: { $sum: 1 } } }), and the final n is returned to you. Because it runs through the aggregation engine, the query planner treats the $match exactly like it would treat a find() filter — if a supporting index exists, it performs an index scan (IXSCAN) and only touches the matching documents; if not, it falls back to a full collection scan (COLLSCAN) and has to inspect every document, even with an empty filter. The number it returns is always exact for the point in time the query runs.
db.collection.estimatedDocumentCount(options) takes no filter at all — you cannot narrow it down. Instead, it asks the storage engine for the number of documents the collection metadata says it holds, which WiredTiger maintains internally as documents are inserted and deleted. This is essentially free: no documents are scanned, no index is touched, it’s just a metadata read. The tradeoff is that on a busy, sharded cluster the number can be a few documents off from the true value at any given instant, because it’s reading cached local statistics rather than counting live.
db.collection.count() is the old, pre-4.0 way to do this and is now deprecated. It has historically produced inconsistent results across sharded clusters and mixes the behavior of the two methods above depending on whether you pass a filter. Modern code should never call it — reach for countDocuments() or estimatedDocumentCount() instead, both of which this lesson covers in depth.
There is also a $count aggregation stage, useful when you want a count as one step inside a larger pipeline (for example, counting how many documents remain after several $match/$lookup stages) rather than as a standalone call.
Syntax
db.collection.countDocuments(filter, options);
db.collection.estimatedDocumentCount(options);
| Parameter | Method | Description |
|---|---|---|
filter |
countDocuments |
A query document, same shape as a find() filter. Pass {} to count every document. Required — use {} explicitly for the whole collection. |
options.limit |
countDocuments |
Stops counting once this many matches are found — useful for “are there at least N matches” checks without scanning further. |
options.skip |
countDocuments |
Skips this many matching documents before counting the rest. |
options.hint |
countDocuments |
Forces use of a specific index by name or shape, overriding the query planner’s choice. |
options.maxTimeMS |
countDocuments |
Aborts the operation if it runs longer than this many milliseconds. |
options.collation |
countDocuments |
Applies language-specific string comparison rules (e.g. case-insensitive matching). |
options.maxTimeMS |
estimatedDocumentCount |
Same time-limit guard; note this method accepts no filter parameter at all. |
Examples
Example 1: Counting an entire collection
await db.orders.countDocuments({});
Output:
5000
Passing an empty filter matches every document in orders, so MongoDB has to visit all 5000 of them one by one inside the $group stage to sum them — there’s no index that can shortcut “match everything,” so this is always a collection scan. For a collection this small it’s instant, but on a 50-million-document collection it would take real time.
Example 2: Counting with a filter
await db.orders.countDocuments({ status: "shipped" });
Output:
1200
Here the filter narrows the match to only documents where status equals "shipped". If an index exists on status, MongoDB uses it to jump straight to the matching documents (IXSCAN) instead of reading the whole collection, keeping this fast even as orders grows into the millions.
Example 3: estimatedDocumentCount vs. a bounded countDocuments
await db.orders.estimatedDocumentCount();
Output:
5000
await db.orders.countDocuments({ status: "pending" }, { limit: 100 });
Output:
100
estimatedDocumentCount() answers “roughly how big is this whole collection” instantly from stored metadata, with no filter option — perfect for a dashboard tile that shows total order volume. The second call demonstrates limit: even if 4,000 orders are "pending", MongoDB stops counting as soon as it reaches 100, which is useful when you only need to know “are there at least 100 pending orders” rather than the exact total.
How it works step by step
When you call countDocuments(filter), here is what actually happens inside the server:
- The driver/mongosh rewrites your call into an aggregation pipeline: a
$matchstage with your filter, followed by a$groupstage that sums1per matching document. - The query planner evaluates the
$matchstage exactly as it would forfind(filter): it checks whether any index’s prefix covers the equality/range predicates in the filter. - If a usable index exists, the storage engine walks the index’s B-tree structure (
IXSCAN), visiting only entries that satisfy the filter — it doesn’t need to load the full documents at all, since it’s only counting. - If no usable index exists, the engine performs a
COLLSCAN, reading every document from disk/cache and testing each one against the filter. - The
$groupstage accumulates a running total as documents stream through it, emitting a single summary document ({ _id: null, n: <count> }) once the stream ends. countDocuments()unwraps that summary document and returns just the numbernto your code.
You can see this decision for yourself by running the underlying aggregation with explain():
await db.orders.explain("executionStats").aggregate([
{ $match: { status: "shipped" } },
{ $group: { _id: null, n: { $sum: 1 } } }
]);
Output (abridged):
{
stages: [
{
"$cursor": {
queryPlanner: {
winningPlan: {
inputStage: {
stage: "IXSCAN",
indexName: "status_1"
}
}
}
}
}
]
}
Seeing IXSCAN in the winning plan confirms the count used the status_1 index instead of scanning the whole collection. If you instead see COLLSCAN, that’s a sign to add an index on the fields you filter on before this query runs against real production volume.
Common Mistakes
Mistake 1: Using the deprecated count()
// Deprecated since MongoDB 4.0 -- avoid in new code
db.orders.count({ status: "shipped" });
count() predates the aggregation-based counting model, produces inconsistent results on sharded clusters, and receives no further improvements. Use countDocuments() instead:
await db.orders.countDocuments({ status: "shipped" });
Mistake 2: Expecting estimatedDocumentCount to accept a filter
// Mistake: this argument is treated as "options", not a filter -- it's silently ignored
await db.orders.estimatedDocumentCount({ status: "shipped" });
estimatedDocumentCount() only takes an options object (maxTimeMS and similar) — it has no concept of a query filter, so passing one doesn’t error, it’s just ignored, and you silently get the count of the entire collection instead of the subset you meant to check. If you need a filtered count, use countDocuments():
await db.orders.countDocuments({ status: "shipped" });
Mistake 3: Comparing _id to a raw string
// Mistake: req.params.id is a plain string, but _id is stored as an ObjectId
const id = "64fbd6a1c2a4f1a2b3c4d5e6";
await db.orders.countDocuments({ _id: id }); // always returns 0
ObjectId and a string with the same hex characters are different BSON types and never compare equal, so this filter matches nothing no matter how many orders exist. Wrap the string in ObjectId before querying:
const id = "64fbd6a1c2a4f1a2b3c4d5e6";
await db.orders.countDocuments({ _id: new ObjectId(id) });
Best Practices
- Use
countDocuments()whenever you need an exact count of documents matching a filter — it’s the only method of the three that’s both accurate and filterable. - Use
estimatedDocumentCount()for fast, whole-collection totals where a number that’s off by a handful of documents is acceptable, like a “Total Users” dashboard tile. - Never use the deprecated
count()in new code. - Build indexes on fields you filter by frequently, and confirm with
explain("executionStats")that your counts useIXSCANrather thanCOLLSCANbefore they run against production-sized data. - If you only need to know whether at least one document matches (an existence check), use
findOne()orcountDocuments(filter, { limit: 1 })rather than a full count — both stop at the first match instead of scanning everything. - Remember that any count is a snapshot: in a high-write collection, the true number may have already changed by the time your application reads the result.
- Always convert string IDs to
ObjectId(new ObjectId(id)) before using them in a filter passed tocountDocuments().
Practice Exercises
- Given a
db.productscollection, write a query that counts how many documents havecategory: "electronics"andpricegreater than100. Then runexplain("executionStats")on the equivalentfind()to check whether an index oncategorywould help. - Compare
await db.logs.estimatedDocumentCount()againstawait db.logs.countDocuments({})on a largelogscollection. Both should return the same (or nearly the same) number — explain in your own words why one can be dramatically faster than the other. - Given a
db.userscollection with alastLogindate field, write acountDocuments()call that counts users who logged in within the last 30 days. Hint: build the cutoff withnew Date(Date.now() - 30 * 24 * 60 * 60 * 1000)and compare with$gte.
Summary
countDocuments(filter, options)gives an exact, filterable count by running an aggregation$match+$grouppipeline, and can use indexes just likefind().estimatedDocumentCount(options)gives a near-instant, whole-collection total straight from storage-engine metadata, but accepts no filter.- The legacy
count()method is deprecated — always usecountDocuments()orestimatedDocumentCount()instead. - An empty filter (
{}) can never use an index, since it matches every document — expect a collection scan. - Use
explain("executionStats")on the equivalent aggregation or query to confirmIXSCANvsCOLLSCANbefore trusting a count’s performance at scale. - Always convert string IDs to
ObjectIdbefore filtering by_id.
