find and findOne
The find() and findOne() methods are how you read data out of a MongoDB collection — they are the equivalent of a SQL SELECT statement, but built around matching documents against a query filter rather than scanning rows against a WHERE clause. find() returns a cursor over every document that matches, while findOne() returns a single document (or null) and behaves like a shortcut for find().limit(1). Getting comfortable with these two methods, and with the query filter syntax they share, is the foundation for every other query, update, and aggregation operation you will write in MongoDB.
Overview: How find() and findOne() Work
Every document in a MongoDB collection is stored as BSON (Binary JSON) — a binary-encoded superset of JSON that adds types plain JSON does not have, such as ObjectId, a native Date, 64-bit integers, and Decimal128. When you call find(), mongosh (or the driver) serializes your query filter into BSON, sends it to the server, and the server’s query planner decides how to satisfy it.
A query filter is just a JS object whose keys are field names (or query operators) and whose values are the conditions to match: { status: "shipped" } matches documents where the status field equals the string "shipped". Nest an operator object to do comparisons, such as { price: { $gt: 100 } }. Combine multiple fields and MongoDB implicitly ANDs them together: { status: "shipped", price: { $gt: 100 } } means both conditions must hold.
find() does not return an array of documents directly — it returns a cursor, a pointer to the result set on the server. In mongosh, when a cursor is the result of an expression typed at the prompt, mongosh automatically iterates it and prints up to 20 documents, then offers it to fetch the next batch. In your own scripts or the Node.js driver you have to iterate it explicitly, with .toArray(), a for await loop, or .forEach() — nothing is transferred from the server until you ask for it. This lazy, batch-based design is what lets find() stream millions of matching documents to a client without loading them all into server memory at once.
findOne() is different: it is not a cursor at all. It returns a single document — the first one the query plan encounters — or null if nothing matches. Internally it behaves like find(filter).limit(1), so as soon as the server finds one matching document it can stop looking; it does not have to keep scanning for more matches. That makes findOne() the right choice whenever you expect, or only care about, a single result — looking a document up by its unique _id, for example.
How MongoDB actually finds the matching documents depends on indexes. Without a usable index, the server performs a collection scan (COLLSCAN): it walks every document in the collection and tests it against your filter. With a matching index, it performs an index scan (IXSCAN): it walks a much smaller B-tree structure that is already sorted by the indexed field(s), jumping straight to the matching range instead of touching every document. The query planner picks a plan based on cached statistics and, for ambiguous cases, a short competition between candidate plans. You can see exactly which plan won by calling .explain("executionStats") on a cursor — the single most useful tool for debugging slow queries in MongoDB.
Syntax
db.collection.find(query, projection);
db.collection.findOne(query, projection);
| Parameter | Type | Description |
|---|---|---|
query |
document (optional) | Filter document describing which documents to match. Omit it or pass {} to match every document in the collection. |
projection |
document (optional) | Which fields to include (1) or exclude (0) in the returned documents. _id is included by default unless you explicitly set _id: 0. You cannot mix inclusion and exclusion in the same projection except for _id. |
find() returns a cursor, so it is common to chain cursor methods onto it:
| Cursor method | Purpose |
|---|---|
.sort({ field: 1 or -1 }) |
Order results ascending (1) or descending (-1). |
.limit(n) |
Return at most n documents. |
.skip(n) |
Skip the first n matching documents; used for pagination, but gets slow with large offsets. |
.toArray() |
Drain the cursor into a plain JS array, for mongosh scripts and the Node.js driver. |
.explain("executionStats") |
Show how the server executed the query instead of returning documents. |
findOne() takes the same two parameters but has no cursor to chain — it returns the matched document, or null, directly.
Examples
Example 1: A simple equality filter
db.products.find({ category: "electronics" });
Output:
[
{ _id: ObjectId("64f1a1b2c3d4e5f6a7b8c9d0"), name: "Wireless Mouse", category: "electronics", price: 25.99 },
{ _id: ObjectId("64f1a2c3d4e5f6a7b8c9d0e1"), name: "USB-C Hub", category: "electronics", price: 42.5 }
]
This filter matches every document in db.products where the category field equals the string "electronics". Because find() returns a cursor, mongosh prints the first batch of matching documents (up to 20) and would show an it prompt if there were more to fetch. Every field from the matched documents is returned, including _id, since no projection was supplied.
Example 2: Comparison operator, projection, sort, and limit chained together
db.products
.find({ price: { $gt: 20 } }, { name: 1, price: 1, _id: 0 })
.sort({ price: -1 })
.limit(2);
Output:
[
{ name: "USB-C Hub", price: 42.5 },
{ name: "Wireless Mouse", price: 25.99 }
]
Here the filter { price: { $gt: 20 } } matches any product priced above 20, the projection { name: 1, price: 1, _id: 0 } keeps only name and price and explicitly drops _id, .sort({ price: -1 }) orders the results from highest to lowest price, and .limit(2) caps the cursor at two documents. The chain reads left to right, but the sort and limit are applied by the server as part of query execution, not by mongosh afterward.
Example 3: Looking up a single document by _id
db.users.findOne({ _id: new ObjectId("64f1b7d2e4a1f2a3b4c5d6e7") });
Output:
{
_id: ObjectId("64f1b7d2e4a1f2a3b4c5d6e7"),
name: "Priya Sharma",
email: "priya@example.com",
createdAt: ISODate("2023-09-01T10:15:00.000Z")
}
findOne() with an _id filter is the fastest possible lookup in MongoDB: every collection has a unique index on _id by default, so this is always an IXSCAN against a single key. Note the string is wrapped in new ObjectId(...) — passing the raw string would not match, since BSON compares an ObjectId and a string as different types entirely.
How It Works, Step by Step
- Parse and validate the filter. mongosh serializes your query document to BSON and sends a
findcommand tomongodover the wire protocol. - Query planning. The query optimizer looks at the filter’s fields and any sort, then checks which indexes exist on the collection. If an index matches the equality, sort, or range shape, it builds one or more candidate plans.
- Plan selection. If more than one index could work, MongoDB runs a brief plan-ranking trial among the top candidates and caches the winner for future queries with the same shape.
- Execution: IXSCAN or COLLSCAN. The winning plan either walks the chosen index’s B-tree to find matching keys (
IXSCAN), fetching only the documents those keys point to, or, with no usable index, walks every document in the collection (COLLSCAN). - Projection and sort applied. Unwanted fields are stripped before documents leave the storage engine. If a sort cannot be satisfied by the index order, MongoDB performs an in-memory sort, which is slower and worth avoiding on large result sets.
- Batching back to the client. Results stream back in batches rather than all at once — this is why
find()gives you a cursor, not an array. - findOne() stops early. Since it behaves like
find(filter).limit(1), execution halts the moment one matching document is produced — no further index or collection scanning happens.
Common Mistakes
Mistake 1: Comparing an ObjectId field to a plain string
The _id field is stored as a BSON ObjectId, a 12-byte value, not a string, so a plain JS string will never equal it even though they render the same way in output. This is one of the most common bugs when the id comes from a URL param, query string, or JSON request body — all of which hand you a string.
// userId comes from a URL param, e.g. req.params.id -- it's a plain string
const userId = "64f1b7d2e4a1f2a3b4c5d6e7";
db.users.findOne({ _id: userId }); // returns null -- no match!
const userId = "64f1b7d2e4a1f2a3b4c5d6e7";
db.users.findOne({ _id: new ObjectId(userId) });
Mistake 2: Querying a large collection with no supporting index
On a collection with millions of documents, a filter on an unindexed field such as sku forces MongoDB to examine every single document, even though only one document actually matched. That is a full collection scan, and it gets slower as the collection grows.
db.products.find({ sku: "ABC-123-XL" }).explain("executionStats");
Output:
{
executionStats: {
executionStages: { stage: "COLLSCAN" },
totalDocsExamined: 2000000,
totalKeysExamined: 0,
nReturned: 1
}
}
The fix is to add an index on the field you filter by; afterward explain() reports IXSCAN and totalDocsExamined drops to match nReturned.
db.products.createIndex({ sku: 1 });
db.products.find({ sku: "ABC-123-XL" }).explain("executionStats");
// now executionStages.stage is "IXSCAN", totalDocsExamined is 1
Mistake 3: Treating the find() cursor like an array
find() never gives you an array — it gives you a cursor object. Treating it like an array, by checking .length or indexing into it, fails silently or throws.
const results = db.products.find({ category: "electronics" });
console.log(results.length); // undefined -- a cursor has no .length
console.log(results[0]); // not a matched document -- a cursor isn't indexable
const results = db.products.find({ category: "electronics" }).toArray();
console.log(results.length);
console.log(results[0]);
Call .toArray(), or iterate with for await...of in async code, to materialize the results into a real array first.
Best Practices
- Create an index for any field you filter or sort on regularly, and confirm it is used with
.explain("executionStats")— do not assume. - Use a projection to return only the fields you need, especially over the network or when documents contain large embedded arrays or binary data.
- Prefer
findOne()overfind().limit(1)when you only want a single document, for clarity — the behavior is the same. - Follow the ESR rule (Equality, Sort, Range) when building compound indexes to back a
find()that has both a filter and a sort. - Always wrap a string-derived
_idinnew ObjectId(...)before querying. - Avoid large
.skip()values for pagination on big collections — skip still has to walk past every skipped document; prefer range-based (“keyset”) pagination using the last seen_idor sort field instead. - Use
.toArray(), or async iteration, explicitly in scripts and application code — do not rely on mongosh’s interactive auto-print behavior outside the shell.
Practice Exercises
- In a
db.orderscollection with fieldsstatusandtotal, write a query that returns only_idandtotalfor every order withstatus: "pending"andtotalgreater than 50, sorted bytotaldescending. - Given a document’s
_idas a string, for example from a web form, write the correctfindOne()call to look it up indb.customers. - Run
.explain("executionStats")on a query against an unindexed field in a large collection, note whether it reportsCOLLSCANorIXSCAN, then create the appropriate index and re-runexplainto confirm the plan changed.
Summary
find(query, projection)returns a cursor over every matching document;findOne(query, projection)returns a single document ornull.- A query filter is a JS object of field and operator conditions; multiple top-level fields are ANDed together.
- Cursors are lazy — nothing is fetched until you iterate, call
.toArray(), or let mongosh auto-print. findOne()behaves likefind().limit(1)and stops scanning as soon as one match is found.- Indexes turn a
COLLSCANinto anIXSCAN— always check with.explain("executionStats")on collections of meaningful size. - Never compare a raw string to an
ObjectIdfield — convert withnew ObjectId(...)first.
