← Back to Index

CRUD
Read

FILE 05_crud_read
TOPIC find · findOne · Cursor · Projection · Pagination
LEVEL Foundation
01
find()
Returns a cursor — not an array
Cursor
// Signature
db.collection.find(query, projection, options)

// All documents in collection
db.users.find({})

// Filter: exact match
db.users.find({ city: "Mumbai" })

// Filter: range with comparison operators
db.products.find({ price: { $gt: 100, $lte: 500 } })

// Filter: logical OR
db.users.find({ $or: [{ city: "Mumbai" }, { city: "Delhi" }] })

find() Returns a Cursor — Not Data

// Cursor is ALWAYS truthy — even with 0 results
const cur = db.users.find({ name: "Ghost" })
if (cur) { ... }      // always enters — cursor object is truthy regardless

// CORRECT ways to check if results exist:
cur.hasNext()         // true if at least one document remains
cur.toArray().length  // convert first, then check length
db.users.countDocuments({ name: "Ghost" })  // modern way to count

// Convert to array (loads all into memory — avoid on large sets)
const docs = db.users.find({}).toArray()

// Iterate with forEach (memory-efficient — processes one at a time)
db.users.find({}).forEach(doc => {
  print(doc.name)
})

Cursor Batch Size

MongoDB fetches results in batches — first batch is 101 documents (or 1MB, whichever is smaller). Subsequent getMore ops fetch up to 16MB. This keeps memory usage low on large collections.

NOTEfind({}) on an empty collection returns an empty cursor — it does not throw an error or return null.
02
findOne()
Returns the document (or null), not a cursor
Single
// Signature
db.collection.findOne(query, projection, options)

// Returns the document OR null — null is falsy
const user = db.users.findOne({ email: "alice@example.com" })
if (user) { print(user.name) }  // safe null check

// Force a specific "first" using sort option
db.orders.findOne(
  { status: "shipped" },
  {},                         // empty projection — all fields
  { sort: { orderDate: -1 } } // most recent order
)

// Suppress _id in result
db.users.findOne({ name: "Alice" }, { name: 1, _id: 0 })
// → { name: "Alice" }

find() vs findOne() Key Differences

Aspectfind()findOne()
ReturnsCursor (always truthy)Document or null (falsy)
Empty resultEmpty cursor — still truthynull — falsy, safe in if()
ScansUntil cursor exhausted or limitStops at first match
ChainableYes — .sort(), .limit(), etc.No — returns document
PerformanceContinues scanningStops at first hit (faster)
TIPfindOne() stops at the first match — faster than find().limit(1) on unindexed fields. Both are equally fast when the field is indexed.
03
Projection
Include/exclude fields · $slice · $elemMatch
Fields
// Inclusion projection (1 = include)
db.users.find({}, { name: 1, email: 1 })
// → { _id: ObjectId("..."), name: "Alice", email: "alice@..." }
// _id is ALWAYS included unless explicitly set to 0

// Exclusion projection (0 = exclude)
db.users.find({}, { password: 0, ssn: 0 })
// → all fields except password and ssn

// Exclude _id from an inclusion projection (only allowed mixed case)
db.users.find({}, { name: 1, email: 1, _id: 0 })
// → { name: "Alice", email: "alice@..." }

Mixed Projection Rules

DANGERYou CANNOT mix inclusion 1 and exclusion 0 in the same projection — except for _id.
Invalid: { name: 1, age: 0 } → MongoServerError
Valid: { name: 1, _id: 0 } → works fine

Array projection: $slice and $elemMatch

// $slice: limit array elements in result
db.posts.find({}, { comments: { $slice: 3 } })      // first 3 comments
db.posts.find({}, { comments: { $slice: -2 } })     // last 2 comments
db.posts.find({}, { comments: { $slice: [5, 3] } }) // skip 5, return next 3

// $elemMatch in projection: return FIRST matching array element only
db.students.find(
  { _id: 1 },
  { grades: { $elemMatch: { score: { $gte: 90 } } } }
)
// Returns only the first grade object where score >= 90
// Does NOT filter all matching elements — only returns the first one
04
Cursor Methods
sort · limit · skip · pagination · explain
Cursor
// sort: 1 = ascending, -1 = descending
db.products.find().sort({ price: 1 })            // cheapest first
db.products.find().sort({ price: -1, name: 1 })  // price desc, then name asc

// limit: cap number of returned documents
db.products.find().limit(10)

// skip: offset from beginning (0-indexed)
db.products.find().skip(20).limit(10)  // page 3 (10 per page)

// Chain order doesn't affect execution — MongoDB always:
// (1) applies sort  (2) applies skip  (3) applies limit
db.products.find().limit(10).sort({ price: 1 }).skip(20)  // same result

// Count total matching documents
db.products.countDocuments({ category: "electronics" })

Pagination: skip() vs Cursor-Based (Range)

MethodMechanismPerformance
skip(n) + limit()Scans and discards first n docsDegrades linearly — slow on deep pages
Cursor-based (_id > lastId)Index range scan from last seen idO(log n) — always fast, any page depth
// skip()-based pagination — gets slow at large offsets
db.orders.find().sort({ _id: 1 }).skip(9990).limit(10)
// MongoDB scans and discards 9990 docs — expensive!

// Cursor-based pagination — always fast
const lastId = ObjectId("507f1f77bcf86cd799439011")  // last _id from prev page
db.orders.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(10)
// Uses _id index directly — no wasted scans

explain() — Inspect Query Plans

// Check if query uses index (IXSCAN) vs full scan (COLLSCAN)
db.products.find({ price: { $gt: 100 } }).explain("executionStats")
// winningPlan.stage: "IXSCAN" = good | "COLLSCAN" = slow, add an index
// Check: totalDocsExamined vs nReturned — should be close to 1:1
TIPUse cursor-based pagination for large datasets. skip() is fine up to a few hundred records but becomes a full-scan bottleneck at large offsets.
05
findAndModify()
Atomic read-modify-write · counters · FIFO queues
Atomic

Atomically finds, modifies (or removes), and returns a single document in one server round-trip. No other operation can intervene between the find and the modify.

// Signature
db.collection.findAndModify({
  query:  { ... },  // filter
  sort:   { ... },  // which doc if multiple match
  update: { ... },  // mutation (XOR remove)
  remove: true,     // delete instead of updating
  new:    true,     // false=pre-update (default) | true=post-update
  upsert: true,     // create doc if not found
  fields: { ... }   // projection on returned document
})

// Atomic counter — guaranteed unique sequential IDs
db.counters.findAndModify({
  query:  { _id: "orderId" },
  update: { $inc: { seq: 1 } },
  new:    true,    // return the INCREMENTED value
  upsert: true     // create counter if it doesn't exist yet
})
// → { _id: "orderId", seq: 1 }  (safe under concurrent requests)

// FIFO job queue — pop oldest pending task atomically
db.tasks.findAndModify({
  query:  { status: "pending" },
  sort:   { createdAt: 1 },  // oldest first
  remove: true               // delete while returning it
})
// Worker gets the task object AND it's removed — prevents double-processing

new flag — What Gets Returned

Scenarionew: false (default)new: true
Document found & updatedPre-update statePost-update state
No match, upsert: truenull (no prior doc)The newly created doc
No match, upsert: falsenullnull
NOTEModern alternatives: findOneAndUpdate(), findOneAndDelete(), findOneAndReplace() provide a cleaner API. From MongoDB 8.0, updateOne() supports sort, making it a lighter option when you don't need the document back.
06
Edge Cases
null · NaN · type sensitivity · $elemMatch · dot notation
Edge Cases

null Matches Null AND Missing Fields

// Two documents with different phone situations
{ _id: 1, phone: null }   // field exists, value is null
{ _id: 2, name: "Bob" }   // phone field is completely absent

db.users.find({ phone: null })
// → returns BOTH _id:1 (null) and _id:2 (missing) — common gotcha!

// Only return explicitly null (exists as null):
db.users.find({ phone: { $eq: null, $exists: true } })   // → _id:1 only

// Only return missing (field doesn't exist):
db.users.find({ phone: { $exists: false } })             // → _id:2 only

NaN Behavior in Queries

// NaN matches only with equality — comparisons return nothing
db.data.find({ score: NaN })            // works — finds NaN docs
db.data.find({ score: { $gt: NaN } })   // returns nothing
db.data.find({ score: { $lt: NaN } })   // returns nothing
// NaN !== null !== undefined — they are distinct BSON types

Type Sensitivity

// age stored as string "25" — won't match number 18 comparison
db.users.insertOne({ name: "Alice", age: "25" })   // wrong type!
db.users.find({ age: { $gt: 18 } })               // returns nothing

// Find type mismatches with $type
db.users.find({ age: { $type: "string" } })        // expose the bad docs

// Fix: convert in place
db.users.find({ age: { $type: "string" } }).forEach(doc => {
  db.users.updateOne({ _id: doc._id }, { $set: { age: parseInt(doc.age) } })
})

Dot Notation — Quotes are Mandatory

// Nested field query — MUST use quoted dot notation
db.users.find({ "address.city": "Mumbai" })      // correct
db.users.find({ address.city: "Mumbai" })        // SyntaxError!

// Array element by index
db.students.find({ "scores.0": { $gte: 90 } })   // first score >= 90

Array Queries: Plain vs $elemMatch

// Document: { scores: [85, 92, 78] }

// Plain — conditions applied to ANY element independently
db.students.find({ scores: { $gt: 80, $lt: 90 } })
// Matches because: 85 > 80 (true) AND 78 < 90 (true) — different elements!
// Returns this doc even though no SINGLE element satisfies both

// $elemMatch — BOTH conditions must apply to the SAME element
db.students.find({ scores: { $elemMatch: { $gt: 80, $lt: 90 } } })
// Only 85 satisfies BOTH > 80 AND < 90 → MATCHES
// 92 > 90, 78 < 80 — these fail individually
WARN$elemMatch vs plain array query is one of the most common MongoDB gotchas. Always use $elemMatch when multiple conditions must apply to the same array element.