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:

  1. The driver/mongosh rewrites your call into an aggregation pipeline: a $match stage with your filter, followed by a $group stage that sums 1 per matching document.
  2. The query planner evaluates the $match stage exactly as it would for find(filter): it checks whether any index’s prefix covers the equality/range predicates in the filter.
  3. 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.
  4. If no usable index exists, the engine performs a COLLSCAN, reading every document from disk/cache and testing each one against the filter.
  5. The $group stage accumulates a running total as documents stream through it, emitting a single summary document ({ _id: null, n: <count> }) once the stream ends.
  6. countDocuments() unwraps that summary document and returns just the number n to 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 use IXSCAN rather than COLLSCAN before they run against production-sized data.
  • If you only need to know whether at least one document matches (an existence check), use findOne() or countDocuments(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 to countDocuments().

Practice Exercises

  • Given a db.products collection, write a query that counts how many documents have category: "electronics" and price greater than 100. Then run explain("executionStats") on the equivalent find() to check whether an index on category would help.
  • Compare await db.logs.estimatedDocumentCount() against await db.logs.countDocuments({}) on a large logs collection. 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.users collection with a lastLogin date field, write a countDocuments() call that counts users who logged in within the last 30 days. Hint: build the cutoff with new 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 + $group pipeline, and can use indexes just like find().
  • 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 use countDocuments() or estimatedDocumentCount() 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 confirm IXSCAN vs COLLSCAN before trusting a count’s performance at scale.
  • Always convert string IDs to ObjectId before filtering by _id.