Filter concepts by levelShowing all levels.

MongoDB · Section 4

CRUD Operations

Level
intermediate
Read
30 min
Concepts
8

The nine CRUD methods in practice, upserts, the field and array update operators, positional update patterns, projection, sort/skip/limit and why deep pagination gets expensive, and batching writes with bulkWrite.

What is true here

  1. insertOne/Many, find/findOne, updateOne/Many, replaceOne, deleteOne/Many — "One" stops at the first match, not "the only match."
  2. upsert: true updates a matching document, or inserts one from the filter and update if nothing matches.
  3. $inc, $mul and the other update operators apply atomically, avoiding the race a read-then-write in application code risks.
  4. skip(N) still walks past N documents internally — deep, page-number-based pagination gets slower as N grows.
  5. bulkWrite's ordered mode (default) stops at the first failure; unordered keeps going and may run out of order.

What you will be able to do

  • Choose the right CRUD method for a given read/write, including the "One" vs. plural distinction
  • Use upsert: true with $setOnInsert for a get-or-create pattern
  • Apply the right update operator for a given field change, and explain why $inc is safer than read-then-write
  • Update an array element positionally without already knowing its index
  • Explain why skip-based pagination degrades at scale, and what avoids that cost

The methods

The nine CRUD methods, the upsert pattern, and the operators that edit specific fields in place.

The CRUD method surface

corebeginner

Nine methods cover CRUD in practice: insertOne/Many create, find/findOne read, updateOne/Many change fields, replaceOne swaps a whole document, deleteOne/Many remove. Each has a "One" and, where it makes sense, a plural form.

Think of it as

The five conceptual verbs from Fundamentals each become one or two real methods: the "One" variant stops after the first match, the plural variant (or find's cursor) keeps going across every match. Learning the pattern — verb + One/Many/plural — makes the nine methods one idea, not nine separate ones to memorize.

text
db.<collection>.<method>(<filter>, <update or doc>, <options>)

What we're doing: Show why calling updateOne where updateMany was intended silently under-applies a change.

one-vs-many.txttext
// Three matching documents: { status: "pending" } x3

db.orders.updateOne({ status: "pending" }, { $set: { status: "shipped" } })
// -> only ONE of the three is updated; the other two are untouched

db.orders.updateMany({ status: "pending" }, { $set: { status: "shipped" } })
// -> all three are updated
2
updateOne stops after the first match, even though the filter matches three documents.
5
updateMany applies the same update to every matching document.

Why this works: The "One" methods are not shorthand for "the only match" — they stop at the first match regardless of how many documents the filter actually matches, which is easy to miss when testing against data that happens to have only one matching document.

Reaching for updateOne/deleteOne out of habit when every match should be affected

Wrong

text
db.orders.updateOne({ status: "pending" }, { $set: { status: "shipped" } })
// intended to ship every pending order, only shipped one

Better

text
db.orders.updateMany({ status: "pending" }, { $set: { status: "shipped" } })

What you see: Only one document changes even though the filter clearly matches several — no error is raised.

Why: updateOne/deleteOne succeed silently after touching exactly one match; nothing signals that more documents matched the filter and were left alone.

One method per verb, "One" vs. plural

Create

insertOne

one document

insertMany

many documents

Read / Update / Delete

find / findOne

all / first match

updateOne / updateMany

first / all matches

deleteOne / deleteMany

first / all matches

  • Create
    • insertOne — one document
    • insertMany — many documents
  • Read / Update / Delete
    • find / findOne — all / first match
    • updateOne / updateMany — first / all matches
    • deleteOne / deleteMany — first / all matches

The nine CRUD methods

The nine CRUD methods
MethodVerbMatchesReturns
insertOne(doc)createn/athe inserted _id
insertMany(docs)createn/ainserted _ids
find(filter)readall matchesa cursor
findOne(filter)readfirst matcha document or null
updateOne(filter, update)change fieldsfirst matcha result summary
updateMany(filter, update)change fieldsall matchesa result summary
replaceOne(filter, doc)swap whole docfirst matcha result summary
deleteOne(filter)removefirst matcha result summary
deleteMany(filter)removeall matchesa result summary

Together

text
db.orders.insertOne({ status: "pending" })
db.orders.find({ status: "pending" })
db.orders.updateOne({ _id: id }, { $set: { status: "shipped" } })
db.orders.deleteMany({ status: "cancelled" })

Remember: insertOne/Many, find/findOne, updateOne/Many, replaceOne, deleteOne/Many. "One" stops at the first match — it does not mean "the only match."

See also: crud overview · upserts

Upserts

coreintermediate

An upsert — updateOne/updateMany with { upsert: true } — updates a matching document if one exists, or inserts a new one built from the filter and update if none does. It replaces a separate "check, then insert or update" round trip.

Think of it as

A plain update assumes the document already exists; an upsert says "make it exist, one way or another" — if the filter matches, patch it; if nothing matches, create it as if the filter's conditions were the starting fields, then apply the update on top.

text
db.<collection>.updateOne(filter, update, { upsert: true })

What we're doing: Show why $setOnInsert matters — without it, every upsert call would reset createdAt.

upsert-counter.txttext
db.counters.updateOne(
  { _id: "orders" },
  {
    $inc: { seq: 1 },
    $setOnInsert: { createdAt: new Date() }
  },
  { upsert: true }
)
// First call (no match): inserts { _id: "orders", seq: 1, createdAt: <now> }
// Second call (matches): { seq: 2 } — createdAt is untouched, not reset
4
$inc runs on both the insert and update branches — the counter always increments.
5
$setOnInsert only applies when a new document is actually inserted — a normal matching update never touches createdAt.

Why this works: Combining $inc (runs every time) with $setOnInsert (runs only on insert) is exactly what a "get-or-create, then increment" counter pattern needs — one atomic call instead of a separate find-or-insert followed by a second update.

Using $set instead of $setOnInsert for fields that should only be set once

Wrong

text
updateOne({ _id: "orders" }, { $inc: { seq: 1 }, $set: { createdAt: new Date() } }, { upsert: true })
// every call resets createdAt, even on an existing document

Better

text
updateOne({ _id: "orders" }, { $inc: { seq: 1 }, $setOnInsert: { createdAt: new Date() } }, { upsert: true })

What you see: A field meant to record original creation time keeps changing on every subsequent upsert call.

Why: $set always applies, on both the insert and update branch; $setOnInsert applies only when the upsert actually creates a new document, which is what "only set this once, at creation" requires.

What upsert: true decides
yesno

Filter runs

Match found?

Update it

Insert new

  • Filter runs
    • leads to Match found?
  • Match found?
    • leads to Update it (yes)
    • leads to Insert new (no)
  • Update it
  • Insert new

What an upsert does in each case

What an upsert does in each case
Filter matches?Result
Yes, one documentthat document is updated, same as a normal update
Yes, several documents (updateMany)all of them are updated
No documentsa new document is inserted, from filter + update + $setOnInsert

Together

text
db.counters.updateOne(
  { _id: "orders" },
  { $inc: { seq: 1 }, $setOnInsert: { createdAt: new Date() } },
  { upsert: true }
)
// first call: no match -> inserts { _id: "orders", seq: 1, createdAt: ... }
// later calls: matches -> increments seq, createdAt untouched ($setOnInsert skipped)

Remember: upsert: true updates on a match, inserts otherwise. $setOnInsert runs only on the insert branch — use it for create-once fields.

See also: crud methods · update operators

Field update operators

coreintermediate

Update operators change specific fields without reading the document first: $set assigns, $unset removes, $inc/$mul do arithmetic in place, $min/$max apply conditionally, $rename renames a field, $currentDate stamps now.

Think of it as

Each operator is a small, atomic edit instruction sent to the database, rather than "read the document, change it in code, write the whole thing back." That distinction is what makes $inc safe under concurrent writes — the database applies the increment directly, so two simultaneous +1s do not race and overwrite each other the way a read-modify-write in application code could.

text
{ $set: {...}, $unset: {...}, $inc: {...}, $mul: {...}, $min: {...}, $max: {...}, $rename: {...}, $currentDate: {...} }

What we're doing: Show why $inc is safe under concurrent writes where a read-modify-write in application code is not.

inc-vs-read-modify-write.txttext
// Safe: the database applies the increment atomically
db.counters.updateOne({ _id: "views" }, { $inc: { count: 1 } })

// Unsafe: two concurrent processes can both read count=5, both write count=6,
// losing one of the two increments
const doc = db.counters.findOne({ _id: "views" });
db.counters.updateOne({ _id: "views" }, { $set: { count: doc.count + 1 } });
2
$inc is a single atomic operation — MongoDB reads and writes the new value internally, with no window for another write to interleave.
6
Reading the value in application code, then writing it back, opens a window where a second concurrent process can read the same stale value.

Why this works: Two processes both incrementing with $inc always end up with the correct total, because each $inc is applied against whatever the current value is at the moment it runs. A read-then-write in application code instead risks both processes computing "5 + 1 = 6" from the same stale read, losing one increment.

Reading a field, computing a new value in code, then $set-ing it back

Wrong

text
const doc = db.counters.findOne({ _id: "views" });
db.counters.updateOne({ _id: "views" }, { $set: { count: doc.count + 1 } });

Better

text
db.counters.updateOne({ _id: "views" }, { $inc: { count: 1 } });

What you see: A counter under concurrent traffic ends up lower than the actual number of events that incremented it.

Why: The read-then-write pattern has a gap between reading the old value and writing the new one, during which another write can happen unseen. $inc removes that gap by applying the change directly, inside the database.

An update operator edits in place, no read-modify-write

document

as stored

$inc: { n: 1 }

applied atomically

document

n is one higher

  1. document — as stored
  2. $inc: { n: 1 } — applied atomically
  3. document — n is one higher

Field update operators

Field update operators
OperatorEffect
$setassigns a field the given value
$unsetremoves the field entirely
$incadds the given amount to a numeric field (can be negative)
$mulmultiplies a numeric field by the given amount
$minsets the field only if the given value is smaller than the current one
$maxsets the field only if the given value is larger than the current one
$renamerenames a field, keeping its value
$currentDatesets a field to the current date, evaluated server-side

Together

text
db.orders.updateOne({ _id: id }, {
  $set: { status: "shipped" },
  $inc: { version: 1 },
  $currentDate: { updatedAt: true }
})

Remember: $set/$unset assign/remove; $inc/$mul do atomic arithmetic; $min/$max apply conditionally; $rename renames; $currentDate stamps now, server-side.

See also: crud methods · array update operators

Advertisement

Shaping the write and the read

Editing arrays without an index in hand, choosing which fields come back, and the cost of deep pagination and batched writes.

Array update operators

standardintermediate

$push appends (allows duplicates); $addToSet appends only if new; $pop removes from an end; $pull/$pullAll remove matching values. $each/$slice/$sort are modifiers inside $push to add several values, cap length, and keep order.

Think of it as

$push and $addToSet answer "how do I add," differing only in whether duplicates are allowed. $pop, $pull and $pullAll answer "how do I remove," differing in whether removal is by position or by value. $each/$slice/$sort are not standalone operators — they are modifiers that ride inside a $push to add many values at once, in order, capped to a size.

text
{ $push: { field: { $each: [...], $slice: N, $sort: {...} } } }

Array update operators

Array update operators
OperatorEffect
$pushappends one value (or, with $each, several)
$addToSetappends a value only if not already present
$popremoves the first (-1) or last (1) element
$pullremoves every element matching a condition
$pullAllremoves every occurrence of the given exact values
$eachmodifier: add several values in one $push
$slicemodifier: cap the array to N elements after the update
$sortmodifier: sort the array after $each adds to it

Together

text
db.posts.updateOne({ _id: id }, {
  $push: {
    comments: {
      $each: [{ text: "nice" }, { text: "thanks" }],
      $slice: -50,
      $sort: { postedAt: -1 }
    }
  }
})

Remember: $push/$addToSet add (with/without duplicates); $pop/$pull/$pullAll remove; $each/$slice/$sort are modifiers inside $push.

See also: update operators · arrays of scalars and documents

Positional update patterns

standardintermediate

The $ positional operator updates the array element that matched the query filter, without knowing its index. $[] updates every element; $[<id>] (filtered positional) updates only elements matching a separate arrayFilters condition.

Think of it as

A plain dot-index update ("items.0.qty") requires already knowing which index to touch. The positional operators exist for the common case of not knowing the index — $ says "whichever element the query just matched," $[] says "all of them," and $[<id>] says "whichever match a separate condition, possibly different from the query filter."

text
{ $set: { "field.$.nested": value } }                          // $
{ $set: { "field.$[].nested": value } }                         // $[]
{ $set: { "field.$[id].nested": value } }, { arrayFilters: [...] } // $[<id>]

The three positional forms

The three positional forms
OperatorUpdatesRequires
$the first array element the query filter matchedthe query filter to reference the array
$[]every element in the arraynothing extra
$[<id>]elements matching an arrayFilters conditionan arrayFilters option

Together

text
// $ : update the matched grade
db.students.updateOne({ _id: id, "grades.grade": "B" }, { $set: { "grades.$.grade": "B+" } })

// $[<id>] : update every grade below 60 to exactly 60
db.students.updateMany({}, { $set: { "grades.$[g].grade": 60 } }, { arrayFilters: [{ "g.grade": { $lt: 60 } }] })

Remember: $ updates the query-matched array element; $[] updates all elements; $[<id>] updates elements matching a separate arrayFilters condition.

See also: array update operators · nested fields and dot notation

Projection and field selection

standardintermediate

A projection, find()'s second argument, chooses which fields come back: { field: 1 } includes only named fields (plus _id by default), { field: 0 } excludes named fields and returns everything else. The two forms cannot normally be mixed.

Think of it as

Inclusion projection is a guest list — only named fields (plus _id) get in. Exclusion projection is the opposite — everyone gets in except the names crossed off. Mixing the two styles in one call is ambiguous about which rule wins, so it is disallowed except for the _id special case.

text
db.<collection>.find(filter, { field1: 1, field2: 1 })   // or { field: 0, ... }

Projection forms

Projection forms
ProjectionReturns
{}every field (the default)
{ title: 1 }only _id and title
{ title: 1, _id: 0 }only title (the one allowed inclusion/exclusion mix)
{ internalNotes: 0 }everything except internalNotes

Together

text
db.books.find({}, { title: 1, _id: 0 })
db.books.find({}, { internalNotes: 0 })
db.books.find({}, { title: 1, internalNotes: 0 })  // ERROR: mixed inclusion/exclusion

Remember: find()'s second argument is a projection: { field: 1 } includes, { field: 0 } excludes. The two forms cannot mix, except { _id: 0 }.

See also: crud methods · nested fields and dot notation

sort, skip, limit — and why skip gets expensive

standardintermediate

sort() orders results, limit() caps how many come back, and skip() discards the first N — but skip still walks past those N documents internally, so a deep skip (page 10,000) gets slower as N grows, even at the same page size.

Think of it as

skip() is like fast-forwarding a tape rather than jumping to a timestamp — the player still has to physically pass every second before it. A cursor pagination scheme, by contrast, jumps straight to "everything after this exact bookmark," which costs the same whether the bookmark is on page 2 or page 10,000.

text
db.<collection>.find(filter).sort({ field: 1 }).skip(N).limit(M)

Pagination cost comparison

Pagination cost comparison
ApproachCost at page 2Cost at page 10,000
skip/limitcheapexpensive — must walk past all prior matches
Cursor (range) paginationcheapcheap — jumps straight to the bookmark via an index

Together

text
// skip/limit: gets slower as the page number grows
db.orders.find().sort({ createdAt: -1 }).skip(200000).limit(20)

// cursor pagination: same cost regardless of how deep
db.orders.find({ createdAt: { $lt: lastSeenCreatedAt } }).sort({ createdAt: -1 }).limit(20)

Remember: skip(N) still walks past N documents internally — deep pagination gets slower. Cursor/range pagination (later, section 37) avoids that cost.

See also: projection and field selection · crud methods

bulkWrite() — ordered vs. unordered

standardintermediate

bulkWrite() sends a mixed batch of insert/update/delete operations in one call. Ordered (the default) stops at the first failure, leaving later operations unrun; unordered keeps going, running every operation regardless of earlier failures.

Think of it as

Ordered bulkWrite is a strict to-do list: if step 3 fails, the list stops there and steps 4 onward never happen. Unordered is a pile of independent errands: one failing does not stop the rest from being attempted — and the ones that can run may finish out of the order they were listed in.

text
db.<collection>.bulkWrite([{ insertOne: {...} }, { updateOne: {...} }, ...], { ordered: true|false })

Ordered vs. unordered bulkWrite

Ordered vs. unordered bulkWrite
ModeOn a failureOrder guaranteed?
ordered: true (default)stops — later operations do not runyes
ordered: falsecontinues — every operation is attemptedno

Together

text
db.orders.bulkWrite([
  { insertOne: { document: { _id: 1, status: "pending" } } },
  { updateOne: { filter: { _id: 2 }, update: { $set: { status: "shipped" } } } },
  { deleteOne: { filter: { _id: 3 } } }
], { ordered: false })

Remember: bulkWrite() batches mixed operations. Ordered (default) stops at the first failure; unordered keeps going and may run out of order.

See also: crud methods · upserts

Advertisement