find() Hands Back a Cursor
find() does not return your documents. It returns a cursor — a pointer to a result set that the server holds open, which you then pull documents from in batches. This matters because a query matching two lakh documents does not load two lakh documents into memory the moment you press Enter. Nothing is fetched until something asks the cursor for data.
In mongosh the shell asks on your behalf and prints the first twenty documents; type it to fetch the next twenty. In Node.js you decide: await cursor.toArray() pulls everything into an array, or for await (const doc of cursor) processes one document at a time and keeps memory flat no matter how large the result is. For a report over a whole collection, always prefer the loop.
findOne() is different in a useful way: it returns the document itself, or null if nothing matched. Use it whenever you expect at most one result, because you can work with the value immediately instead of unwrapping a cursor. Testing the result with if (!user) is then the natural way to handle "not found".
// A cursor — nothing has been fetched yet
const cursor = db.products.find({ category: "Electronics" })
// mongosh prints 20 at a time; type `it` for the next page
db.products.find({ category: "Electronics" })
// findOne returns a document or null
const user = db.users.findOne({ email: "ananya@example.com" })
if (!user) print("no such user")
// In Node.js, stream rather than load everything
// for await (const order of db.collection('orders').find({ paid: false })) {
// await sendReminder(order)
// } .pretty()was needed in the oldmongoshell to format output.mongoshalready prints readable, coloured output, so you can drop it.
The Filter Document
The argument you pass to find() is itself a document, and MongoDB reads it as a description of what a matching document must look like. { city: "Pune" } means "the field city equals the string Pune". Add a second key and both conditions must hold — several keys in one filter are an implicit AND. An empty filter {} matches everything.
String matching is exact and case-sensitive. { city: "pune" } will not match a document storing "Pune". If your application needs case-insensitive lookups on a field like email, the durable fix is to store it lower-cased on the way in, not to reach for a regular expression on every query.
To reach inside a nested object, use dot notation and put the whole path in quotes, because a dot is not legal in an unquoted JavaScript key. And be careful with the alternative: { address: { city: "Pune" } } does not mean "whose address has city Pune". It means "whose address object is exactly this object" — same fields, same values, in the same order. A document whose address also carries a pincode will not match. This catches people constantly, and dot notation is almost always what you actually wanted.
db.users.find({}) // everything
db.users.find({ city: "Pune" }) // exact, case-sensitive
db.users.find({ city: "Pune", age: 22 }) // implicit AND
// Nested fields — dot notation, quoted
db.users.find({ "address.city": "Pune" }) // what you want
db.users.find({ address: { city: "Pune" } }) // exact whole-object match
// Arrays: this matches any document whose tags array CONTAINS "sale"
db.products.find({ tags: "sale" })
// ...while this matches only an array that IS exactly ["sale"]
db.products.find({ tags: ["sale"] }) - The same dot-notation rule applies to arrays of objects:
db.orders.find({ "items.sku": "AB-1" })finds orders where some item has that SKU.
Projection: Asking for Less
The second argument to find() is a projection — a list of the fields you want back. Set a field to 1 to include it, or 0 to exclude it. This is not a cosmetic feature. A user document that carries a long activity array is expensive to send over the network and to hold in memory, and if all your page needs is a name and an email, asking for the rest is pure waste. On a cloud database you are often paying for that traffic.
There is one rule the server enforces: you may not mix inclusions and exclusions in the same projection. Either you list the fields you want, or you list the fields you do not want. The single exception is _id, which is included by default and may be switched off alongside an inclusion list — which is why { name: 1, email: 1, _id: 0 } is legal and extremely common.
// Include only these fields
db.users.find({ city: "Pune" }, { name: 1, email: 1 })
// _id comes along unless you say otherwise
// Drop _id too
db.users.find({ city: "Pune" }, { name: 1, email: 1, _id: 0 })
// Or exclude the heavy fields and keep the rest
db.users.find({}, { activityLog: 0, passwordHash: 0 })
// NOT allowed — mixing 1 and 0 (except for _id)
// db.users.find({}, { name: 1, city: 0 })
// MongoServerError: Cannot do inclusion on field city in exclusion projection - Never send a password hash to the browser because you forgot a projection. Excluding sensitive fields at the query is more reliable than remembering to delete them later in your route handler.
Sorting, Limiting and Skipping
Cursor methods chain onto find(). sort() takes a document of field names with 1 for ascending and -1 for descending; limit() caps how many documents come back; skip() jumps past the first n results. The order you write them in does not matter — the server always applies sort, then skip, then limit.
Sorting has a trap worth knowing. When two documents have the same value in the sort field, MongoDB makes no promise about which comes first, and the answer can change between runs. If you sort products by price and paginate, a product can appear on both page one and page two, or on neither. The fix is a tiebreaker: sort by { price: -1, _id: 1 } so the order is fully determined.
A second trap costs performance rather than correctness. skip(100000) does not teleport — the server still walks past a lakh of documents before it starts returning anything, so page 5,000 of a list is far slower than page 1. For a few pages skip is perfectly fine. For deep pagination or an infinite-scroll feed, remember the last document you showed and ask for the ones after it, which lets an index jump straight to the right place.
// Cheapest electronics first, best five
db.products.find({ category: "Electronics" })
.sort({ price: 1 })
.limit(5)
// Stable ordering: add a tiebreaker
db.products.find().sort({ price: -1, _id: 1 })
// Classic pagination — fine for the first few pages
const perPage = 10, page = 3
db.products.find().sort({ _id: 1 }).skip((page - 1) * perPage).limit(perPage)
// Better for endless scrolling: continue after the last id you showed
const lastId = ObjectId("66ab12cd34ef56789012ab34")
db.products.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(10) - A sort that cannot use an index is performed in memory and is subject to a size limit; on a large result set it fails outright rather than running slowly. If a sort suddenly errors as your data grows, the answer is an index on the sort field — see the lesson on indexing.
Choosing the Right Read for the Job
Most read bugs come from picking a method that answers a slightly different question from the one being asked. Match the method to the question and the code becomes easier to read as well as faster.
- Need at most one document, such as a login lookup?
findOne(filter)— you get the document ornull. - Need a list to display?
find(filter, projection).sort(...).limit(...)— always limit what a page renders. - Only need to know whether anything matches?
findOne(filter, { _id: 1 })is cheaper than counting, because it stops at the first hit. - Need an exact number for the user interface?
countDocuments(filter). - Need a rough size of a whole collection while exploring?
estimatedDocumentCount()returns instantly. - Need to process every document in a huge collection? Iterate the cursor instead of calling
toArray(), so memory stays flat.
