Skip to content

Finding documents

Reading is where a database earns its keep, and MongoDB gives you two doors into the same room. findOne is for when you expect a single document - “who is the member named Grace?” - and hands you that one document or nothing at all. find is for when you might get several - “which members joined in 2023?” - and hands you a cursor you can iterate over. Both take the same kind of filter, a document that describes which records you want.

Every output below was captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 (the Python, Go and Rust tabs were each run once in a scratch program on the same server). Object ids will differ on your machine.

Each lesson seeds its own data so you can open it on its own. Here are six library members - the same shape used through the whole course:

mongosh/find-seed.js
db.members.insertMany([
{ name: "Ada", joined: 2023, fines: 0, city: "Cambridge", borrowed: ["Compilers"] },
{ name: "Grace", joined: 2024, fines: 0, city: "Arlington", borrowed: ["Compilers", "Algorithms"] },
{ name: "Linus", joined: 2023, fines: 2, city: "Helsinki", borrowed: [] },
{ name: "Margaret", joined: 2023, fines: 0, city: "Cambridge", borrowed: ["Algorithms"] },
{ name: "Edsger", joined: 2022, fines: 5, city: "Austin", borrowed: ["Compilers", "Algorithms", "Databases"] },
{ name: "Barbara", joined: 2025, fines: 1, city: "Boston", borrowed: [] },
]);
assert.strictEqual(db.members.countDocuments(), 6);

(For Python, Go and Rust, insert the same six documents as in the previous lesson.)

The difference is shape, not syntax. findOne returns at most one document directly. find returns a cursor - a lazy handle the driver iterates to pull documents in batches - so you usually turn it into an array (or loop over it) to see the results.

mongosh/find-one.js
const member = db.members.findOne({ name: "Grace" });
console.log(member);
console.log(db.members.findOne({ name: "Nobody" })); // no match
assert.strictEqual(member.name, "Grace");

The single matching document comes back in full, including the _id, and a miss is null (not an error) in the shell and Node:

{
_id: ObjectId('6abca853ca24543a2bc8573d'),
name: 'Grace',
joined: 2024,
fines: 0,
city: 'Arlington',
borrowed: [ 'Compilers', 'Algorithms' ]
}
null

How each driver reports “no such member” differs, and it matters for error handling: Python returns None, Rust returns None inside Option, Node returns null - but Go’s FindOne(...).Decode() returns an error, mongo: no documents in result (mongo.ErrNoDocuments), which you must treat as “not found”, not as a failure.

An empty filter matches everything, which is how you read a whole collection. With find you get a cursor over all of it:

mongosh/find-all.js
const cursor = db.members.find({});
console.log(cursor.constructor.name); // a Cursor, not an array
const all = cursor.toArray();
console.log(all.map((m) => m.name));
assert.strictEqual(all.length, 6);
Cursor
[ 'Ada', 'Grace', 'Linus', 'Margaret', 'Edsger', 'Barbara' ]

“Lazy handle” is not a metaphor. We turned on the profiler for a scratch database, inserted 1000 small documents and counted the server round trips for four reads:

mongosh/find-batches.js
db.big.insertMany(Array.from({ length: 1000 }, (_, i) => ({ i, pad: "x".repeat(50) })));
const trip = (label, fn) => {
db.setProfilingLevel(2); // profile every operation of THIS database only
const since = new Date();
const n = fn();
db.setProfilingLevel(0);
const ops = db.system.profile.find({ ts: { $gte: since }, ns: db.getName() + ".big" }).sort({ ts: 1 }).toArray();
console.log(label, "docs:", n, "| server round trips:", ops.map((o) => `${o.op}(${o.nreturned})`).join(" "));
return ops;
};
const a = trip("find().toArray() ", () => db.big.find().toArray().length);
const b = trip("find().batchSize(100) ", () => db.big.find().batchSize(100).toArray().length);
const c = trip("find().limit(5) ", () => db.big.find().limit(5).toArray().length);
const d = trip("findOne() ", () => (db.big.findOne() ? 1 : 0));
assert.strictEqual(a.length, 2);
assert.strictEqual(c.length, 1);
find().toArray() docs: 1000 | server round trips: query(101) getmore(899)
find().batchSize(100) docs: 1000 | server round trips: query(100) getmore(100) getmore(100) getmore(100) getmore(100) getmore(100) getmore(100) getmore(100) getmore(100) getmore(100) getmore(0)
find().limit(5) docs: 5 | server round trips: query(5)
findOne() docs: 1 | server round trips: query(1)

The first reply carries 101 documents, the next getMore the rest (up to the reply size cap); batchSize(100) forces eleven round trips; limit(5) and findOne need exactly one. That is also why the shell prints 20 documents and then Type "it" for more: it is the shell asking the cursor for its next batch.

A filter that names a field and a value means “this field equals this value.” Stack several fields in one filter and MongoDB treats them as an implicit AND - a document must match every condition to be returned. Here we ask for members who joined in 2023 and have no outstanding fines:

mongosh/find-equality.js
const matches = db.members.find({ joined: 2023, fines: 0 }, { _id: 0, name: 1 }).toArray();
console.log(matches);
console.log(db.members.find({ joined: 2099, fines: 0 }).toArray()); // a miss: empty array, no error
assert.strictEqual(matches.map((m) => m.name).join(), "Ada,Margaret");

Two members satisfy both conditions; a filter nobody satisfies returns an empty list:

[ { name: 'Ada' }, { name: 'Margaret' } ]
[]

(Node printed [ 'Ada', 'Margaret' ]; Python, Go and Rust each returned the same 2 documents.)

By default a read returns every field of every matching document. A projection lets you ask for just the fields you care about, which trims the data sent over the wire. You pass a second document where 1 means “include this field.” Here we want only names, and we explicitly turn the _id off by setting it to 0:

mongosh/find-projection.js
console.log(db.members.find({ joined: 2023 }, { name: 1, _id: 0 }).toArray());
console.log(db.members.find({}, { name: 1 }).limit(1).toArray()); // _id sneaks in
let err;
try { db.members.find({}, { name: 1, fines: 0 }).toArray(); } catch (e) { err = e; } // mixing include and exclude
console.log(err.name, err.code, err.message);
assert.strictEqual(err.code, 31254);

Now each result carries only the requested field - and the two traps are real:

[ { name: 'Ada' }, { name: 'Linus' }, { name: 'Margaret' } ]
[ { _id: ObjectId('6abca853ca24543a2bc8573c'), name: 'Ada' } ]
MongoServerError 31254 Cannot do exclusion on field fines in inclusion projection

_id is included unless you switch it off with _id: 0, and mixing inclusion (name: 1) with exclusion (fines: 0) is rejected by the server (code 31254).

In Compass: the query bar has a Filter field and a Find button; the Options drop-down adds Project, Sort, Limit, Skip, Collation and Max Time MS (Compass documentation, mongodb.com/docs/compass/current/query/filter, fetched 2026-09-30). Note the Compass filter bar accepts every query operator except $text and $expr.

  • find returns a cursor, not a list. Nothing beyond the first batch is fetched until you iterate it (or call a helper like toArray). A findOne is effectively a cursor that stops after the first document.
  • The _id field is included by default in every projection. If you do not want it, switch it off explicitly with _id: 0.
  • Don’t mix 1 and 0 in the same projection except for _id. Either list the fields you want, or list the fields you want to drop - not both.
  • A filter that matches nothing returns an empty cursor (or, for findOne, null/None; in Go an ErrNoDocuments error). That is a normal answer, not a failure.
  • A filter is an ordinary object in your language, so a repeated key silently keeps only the last value (see below).

Ask an assistant for “members with a small balance, greater than 0 and under 5” and you may get:

🐛 Spot the bug

// AI comment: "fines greater than 0 AND less than 5"
const owing = await db.collection("members")
.find({ fines: { $gt: 0 }, fines: { $lt: 5 } })
.toArray();
Show the answer

A JavaScript object literal cannot hold the same key twice: the second fines silently replaces the first, so the filter that reaches the server is just { fines: { $lt: 5 } }. On our six members that returned five documents, including Ada, Grace and Margaret who owe nothing:

[ 'Ada 0', 'Grace 0', 'Linus 2', 'Margaret 0', 'Barbara 1' ] <- find({ fines: {$gt: 0}, fines: {$lt: 5} })
[ 'Linus 2', 'Barbara 1' ] <- find({ fines: {$gt: 0, $lt: 5} })

Put both operators in one sub-document, or use an explicit $and. This fence proves the fix returns exactly the two members who owe a small amount:

mongosh/find-range-fix.js
db.members.drop();
db.members.insertMany([
{ name: "Ada", fines: 0 }, { name: "Grace", fines: 0 }, { name: "Linus", fines: 2 },
{ name: "Margaret", fines: 0 }, { name: "Edsger", fines: 5 }, { name: "Barbara", fines: 1 },
]);
const wrong = db.members.find({ fines: { $gt: 0 }, fines: { $lt: 5 } }, { _id: 0, name: 1, fines: 1 }).toArray();
const right = db.members.find({ fines: { $gt: 0, $lt: 5 } }, { _id: 0, name: 1, fines: 1 }).toArray();
const explicit = db.members.find({ $and: [{ fines: { $gt: 0 } }, { fines: { $lt: 5 } }] }, { _id: 0, name: 1 }).toArray();
console.log(wrong.map((d) => `${d.name} ${d.fines}`), right.map((d) => `${d.name} ${d.fines}`), explicit.length);
assert.strictEqual(wrong.length, 5);
assert.strictEqual(right.length, 2);
assert.strictEqual(explicit.length, 2);
[ 'Ada 0', 'Grace 0', 'Linus 2', 'Margaret 0', 'Barbara 1' ] [ 'Linus 2', 'Barbara 1' ] 2

A read is filter, then shape: the filter (a document) decides which documents match, the projection decides which fields come back, and the cursor decides how many round trips it takes. find gives you the cursor; findOne is find().limit(1) unwrapped. Keep the filter server-side and small, and you keep the wire small.

ChoiceBenefitCost
A plain equality filter (field: value)Simple to write and read, uses an index directly and efficientlyLimited to answering “equals” questions only
A multi-field filter with implicit ANDSharper, more precise filtering without extra operatorsThe more fields, the more you need a compound index matching that field order, or the query slows down
A filter using richer operators ($gt, $in, $regex, etc.)Highly flexible, can answer range or pattern questionsHarder to design an index for; some operators (like an unanchored $regex) cannot use an index efficiently
Default batch sizes (toArray)2 round trips for 1000 documents (101 + 899)Whole result held in memory; for huge results iterate the cursor instead
Small batchSizeBounded memory per batch11 round trips for the same 1000 documents
  • “find returns the results the instant you call it.” - it returns a lazy cursor. Forgetting toArray()/iteration produces no error, but no data either.
  • “find({}) and filtering in client code is fine.” - send the filter to MongoDB so it filters at the query level, sends less over the wire and lets an index help.
  • “find and findOne are interchangeable.” - findOne returns a plain document (or null); find().toArray()[0] fetches a whole batch to use one document.
  • “{ a: {$gt: 0}, a: {$lt: 5} } is a range.” - the second key replaces the first; the real run returned five documents instead of two.
  • “A miss is an error.” - not in the shell, Node, Python or Rust; in Go Decode returns mongo: no documents in result.

Real-world example

The Compass query-bar documentation shows the same filter-document model in a GUI: the Filter field takes any query operator except $text and $expr, with Project, Sort and Limit under Options (Compass: filter documents, fetched 2026-09-30).

Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (the Go, Python and Rust outputs matched: Grace found, 6 documents, 2 equality matches, 3 projected names) but they are not executed by the course’s automated checker.

What does find return, compared with findOne?
In a filter with two fields, how are the conditions combined?
How do you keep the _id out of a projection?
What does find({ fines: { $gt: 0 }, fines: { $lt: 5 } }) actually send to the server (JavaScript)?
You call db.big.find().batchSize(100).toArray() on 1000 documents. Roughly how many server round trips?