Skip to main content
Published / updated

CRUD Operations

Before you start

You need: a working connection (Article 01).

Time: about 45 minutes, plus the practice.

Learning objective

Perform every basic operation on a collection, and interpret the result each one returns.

Topics

  • Inserting
  • Finding
  • Update operators
  • Update versus replace
  • Upserts
  • Deleting
  • Reading the result objects
  • Bulk writes
  • Diagnosing a no-op

Inserting

db.students.insertOne({
schoolId: 1,
name: "Ravi Kumar",
rollNumber: "NCA-2024-0012",
className: "10th",
section: "A",
dateOfBirth: ISODate("2009-05-14"),
parentPhone: "9951510727",
status: "Active"
})
{
acknowledged: true,
insertedId: ObjectId("6650a1b2c3d4e5f6a7b8c9d0")
}
db.students.insertMany([
{ schoolId: 1, name: "Priya Sharma", rollNumber: "NCA-2024-0018", className: "10th" },
{ schoolId: 1, name: "Arjun Reddy", rollNumber: "NCA-2024-0031", className: "9th" }
])

insertMany stops at the first failure by default, leaving earlier documents inserted. That is a partial import with no rollback:

db.students.insertMany(documents, { ordered: false })

ordered: false continues past failures and reports every error at the end — the right choice for a bulk import where one bad row should not stop the rest.

Finding

db.students.find() // everything
db.students.find({ className: "10th" })
db.students.find({ className: "10th", section: "A" }) // AND
db.students.findOne({ rollNumber: "NCA-2024-0012" }) // one document, or null

find returns a cursor; findOne returns a document or null.

db.students.find().count() // deprecated
db.students.countDocuments({ className: "10th" })
db.students.estimatedDocumentCount()

countDocuments counts accurately by running the query. estimatedDocumentCount reads collection metadata — instant, and only valid for an unfiltered count.

Nested fields use dot notation, quoted:

db.students.find({ "address.city": "Hyderabad" })

Comparison is exact and type-sensitive. { marks: "87" } does not match a document storing 87 as a number — no coercion, no error, no results.

Update operators

db.students.updateOne(
{ rollNumber: "NCA-2024-0012" },
{ $set: { className: "11th", section: "B" } }
)
{
acknowledged: true,
matchedCount: 1,
modifiedCount: 1
}

matchedCount and modifiedCount are different numbers, and the difference matters:

ResultMeaning
matched: 0, modified: 0The filter found nothing
matched: 1, modified: 0Found, but the value was already that
matched: 1, modified: 1Found and changed

A caller checking only modifiedCount treats "already correct" as "not found" — reporting a failure for a successful no-op.

OperatorDoes
$setSet a field, creating it if absent
$unsetRemove a field entirely
$incAdd to a number
$mulMultiply
$min / $maxSet only if lower / higher
$renameRename a field
$currentDateSet to now
$setOnInsertSet only when an upsert inserts
db.students.updateOne(
{ _id: studentId },
{
$set: { className: "11th" },
$unset: { scholarship: "" },
$inc: { promotionCount: 1 },
$currentDate: { updatedAt: true }
}
)

$unset removes the field; the value passed is ignored, so "" is conventional. Removing a field is different from setting it to null{ field: null } matches documents where the field is null and where it is absent, which is a frequent surprise.

db.feeAccounts.updateOne(
{ _id: accountId },
{ $inc: { paidAmount: NumberDecimal("8000.00") } }
)

$inc is atomic. Reading a value, adding to it in application code and writing it back loses one of two concurrent updates; $inc cannot.

db.students.updateMany(
{ schoolId: 1, className: "10th" },
{ $set: { className: "11th" } }
)

updateMany with a filter matching everything updates every document. There is no confirmation prompt.

Update versus replace

// Update — changes only the named fields
db.students.updateOne({ _id: id }, { $set: { className: "11th" } })

// Replace — the ENTIRE document becomes this
db.students.replaceOne({ _id: id }, { className: "11th" })

replaceOne destroys every field not in the replacement. After that second call the document has an _id and a className — the name, roll number, phone and everything else are gone.

// The same mistake, in update form
db.students.updateOne({ _id: id }, { className: "11th" })

Without $set, older drivers treated this as a replacement. Modern versions reject it, but the pattern still appears in application code where an object is passed straight through:

// Wrong — a partial object from an HTTP request replaces the document
await collection.replaceOne({ _id: id }, request.body);

// Right
await collection.updateOne({ _id: id }, { $set: request.body });

Always use $set unless you genuinely intend to replace. This is MongoDB's version of the over-posting problem — and it should still be a whitelisted field set, not the raw request body.

Upserts

db.students.updateOne(
{ schoolId: 1, rollNumber: "NCA-2024-0044" },
{
$set: { name: "Sneha Patel", className: "9th", section: "A" },
$setOnInsert: { createdAt: new Date(), status: "Active" }
},
{ upsert: true }
)

Update if the filter matches; insert if it does not.

{ acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: ObjectId("...") }

upsertedId is present only when a document was inserted, which is how you tell the two outcomes apart.

$setOnInsert applies only on insert. createdAt set with $set would be overwritten on every subsequent update — a common bug in an idempotent import.

The filter's equality fields are copied into a new document, so schoolId and rollNumber above are set automatically.

An upsert without a unique index is a race. Two concurrent upserts with the same filter can both find nothing and both insert:

db.students.createIndex({ schoolId: 1, rollNumber: 1 }, { unique: true })

The index makes the second fail rather than duplicate.

Deleting

db.students.deleteOne({ rollNumber: "NCA-2024-0012" })
db.students.deleteMany({ schoolId: 1, status: "Inactive" })
db.students.deleteMany({}) // EVERY document
{ acknowledged: true, deletedCount: 1 }

deleteMany({}) empties the collection with no prompt. drop() removes the collection and its indexes.

Prefer soft delete

db.students.updateOne(
{ schoolId: 1, rollNumber: "NCA-2024-0012" },
{ $set: { status: "Inactive", deactivatedAt: new Date() } }
)

A student has exam results, attendance and payment history. MongoDB has no foreign keys, so a hard delete leaves those documents referencing an id that no longer exists — and nothing prevents or reports it.

That is a real difference from SQL Server, where the foreign key rejects the delete. In MongoDB, referential integrity is entirely your application's responsibility.

Soft delete creates one obligation: every query must exclude inactive documents.

db.students.find({ schoolId: 1, status: { $ne: "Inactive" } })

Miss that filter in one report and removed students reappear — reported as "delete does not work".

Reading the result

const result = db.students.updateOne(filter, update)

if (result.matchedCount === 0) {
// no such document
} else if (result.modifiedCount === 0) {
// found, already had that value
}

An operation matching nothing is not an error. MongoDB returns success, and code that does not check reports "Saved" while nothing changed.

OperationReturns
insertOneinsertedId
insertManyinsertedIds, insertedCount
updateOne / updateManymatchedCount, modifiedCount, upsertedId
replaceOneSame as update
deleteOne / deleteManydeletedCount
db.students.findOneAndUpdate(
{ _id: id },
{ $set: { className: "11th" } },
{ returnDocument: "after" }
)

findOneAndUpdate performs the update and returns the document — atomically, in one round trip. returnDocument: "after" gives the new state; the default is "before".

That atomicity matters for anything like claiming a queue item, where a separate read and update would let two workers claim the same document.

Bulk writes

db.students.bulkWrite([
{ insertOne: { document: { schoolId: 1, name: "Kiran Rao", rollNumber: "NCA-2024-0051" } } },
{ updateOne: { filter: { rollNumber: "NCA-2024-0012" }, update: { $set: { className: "11th" } } } },
{ updateMany: { filter: { className: "10th" }, update: { $set: { promoted: true } } } },
{ deleteOne: { filter: { rollNumber: "NCA-2024-0099" } } }
], { ordered: false })

One round trip for many operations. ordered: false continues past failures and may run them in parallel.

bulkWrite is not a transaction. Operations succeed or fail individually, and there is no rollback. Transactions are a separate feature, covered in the integration article.

For a large import, batch in chunks of a few thousand rather than sending one enormous array.

Diagnosing a no-op

An update or delete that appears to do nothing is the most common MongoDB support question. Work in this order.

1. Run the filter as a find first.

db.students.find({ schoolId: 1, rollNumber: "NCA-2024-0012" })

No results means the filter is wrong, not the update.

2. Check the types.

db.students.findOne({ rollNumber: "NCA-2024-0012" })

If the stored value is a number, a string filter matches nothing. typeof in mongosh, or the Schema tab in Compass, shows what is actually stored.

3. Check _id is an ObjectId, not a string.

db.students.find({ _id: "6650a1b2c3d4e5f6a7b8c9d0" }) // nothing
db.students.find({ _id: ObjectId("6650a1b2c3d4e5f6a7b8c9d0") }) // correct

4. Check the collection name. A typo creates a new empty collection silently. show collections.

5. Check the database. db shows the current one; use switches lazily, so a typo creates an empty database.

6. Read matchedCount, not just modifiedCount.

SymptomCause
matched: 0Filter wrong, type mismatch, or wrong collection
matched: 1, modified: 0The value was already that
Fields disappearedreplaceOne, or an update without $set
Deleted documents still appearMissing soft-delete filter
Duplicate documents after an upsertNo unique index — a race
A partial importinsertMany without ordered: false

Errors you will hit

What you seeCauseFix
matchedCount: 0Filter matched nothingTest the filter with find first
modifiedCount: 0 but matched 1The value was already thatNot an error
Update replaced the whole documentUsed replaceOne, or omitted $setAlways use $set
E11000 duplicate key errorUnique index violatedThe document already exists
Deleted more than intendedEmpty filter {}Always find with the same filter first

An update without $set replaces the entire document. Every field you did not mention is gone, and there is no error.

Common mistakes

  • replaceOne where updateOne with $set was meant
  • An update without $set
  • Passing a raw request body as the update
  • Checking modifiedCount instead of matchedCount
  • Not checking the result at all
  • updateMany or deleteMany with an over-broad filter
  • insertMany without ordered: false in an import
  • An upsert without a unique index
  • createdAt with $set instead of $setOnInsert
  • Hard delete leaving orphaned references
  • Forgetting the soft-delete filter on a read
  • Querying _id with a string
  • Type mismatch between the filter and the stored value

Practice

The course exercise is write filtered CRUD queries.

  1. Insert five students with insertMany. Read the returned insertedIds.
  2. Insert a batch containing one duplicate _id, first ordered and then with ordered: false. Compare how many were inserted.
  3. Update one student's class with $set. Read matchedCount and modifiedCount.
  4. Run the same update again. Confirm matched: 1, modified: 0 and explain it.
  5. Update with a filter matching nothing. Confirm success with matched: 0.
  6. Use replaceOne with only { className: "11th" }. Read the document afterwards and list what was lost.
  7. Restore it and use $set instead.
  8. Increment a fee balance with $inc using NumberDecimal. Then do it by read-modify-write from two shells at once and compare.
  9. $unset a field, then query { field: null }. Confirm it matches both absent and null.
  10. Upsert a student twice with $setOnInsert: { createdAt }. Confirm createdAt did not change on the second call.
  11. Move createdAt into $set and repeat. Confirm it changed.
  12. Run two concurrent upserts with no unique index and confirm the duplicate. Add the index and repeat.
  13. Hard-delete a student who has exam-result documents. Query the results and confirm they now reference nothing.
  14. Soft-delete instead. Then run a report query without the status filter and confirm the student reappears.
  15. Use findOneAndUpdate with returnDocument: "after".
  16. Query _id with a raw string, then with ObjectId().

Exercises 6, 12 and 13 correspond to data loss, a duplicate race and orphaned references.

You can now

  • Insert, find, update and delete documents
  • Always use $set for updates
  • Read matchedCount and modifiedCount
  • Test a filter with find before updating or deleting
  • Recognise a duplicate key error

Review questions

  1. What is the difference between matchedCount and modifiedCount?
  2. What does replaceOne do that updateOne with $set does not?
  3. Why does $setOnInsert exist?
  4. Why is a hard delete more dangerous in MongoDB than in SQL Server?

Next: Querying and filtering