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:
| Result | Meaning |
|---|---|
matched: 0, modified: 0 | The filter found nothing |
matched: 1, modified: 0 | Found, but the value was already that |
matched: 1, modified: 1 | Found and changed |
A caller checking only modifiedCount treats "already correct" as "not found" — reporting a failure for a successful no-op.
| Operator | Does |
|---|---|
$set | Set a field, creating it if absent |
$unset | Remove a field entirely |
$inc | Add to a number |
$mul | Multiply |
$min / $max | Set only if lower / higher |
$rename | Rename a field |
$currentDate | Set to now |
$setOnInsert | Set 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.
| Operation | Returns |
|---|---|
insertOne | insertedId |
insertMany | insertedIds, insertedCount |
updateOne / updateMany | matchedCount, modifiedCount, upsertedId |
replaceOne | Same as update |
deleteOne / deleteMany | deletedCount |
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.
| Symptom | Cause |
|---|---|
matched: 0 | Filter wrong, type mismatch, or wrong collection |
matched: 1, modified: 0 | The value was already that |
| Fields disappeared | replaceOne, or an update without $set |
| Deleted documents still appear | Missing soft-delete filter |
| Duplicate documents after an upsert | No unique index — a race |
| A partial import | insertMany without ordered: false |
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
matchedCount: 0 | Filter matched nothing | Test the filter with find first |
modifiedCount: 0 but matched 1 | The value was already that | Not an error |
| Update replaced the whole document | Used replaceOne, or omitted $set | Always use $set |
E11000 duplicate key error | Unique index violated | The document already exists |
| Deleted more than intended | Empty 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
replaceOnewhereupdateOnewith$setwas meant- An update without
$set - Passing a raw request body as the update
- Checking
modifiedCountinstead ofmatchedCount - Not checking the result at all
updateManyordeleteManywith an over-broad filterinsertManywithoutordered: falsein an import- An upsert without a unique index
createdAtwith$setinstead of$setOnInsert- Hard delete leaving orphaned references
- Forgetting the soft-delete filter on a read
- Querying
_idwith a string - Type mismatch between the filter and the stored value
Practice
The course exercise is write filtered CRUD queries.
- Insert five students with
insertMany. Read the returnedinsertedIds. - Insert a batch containing one duplicate
_id, first ordered and then withordered: false. Compare how many were inserted. - Update one student's class with
$set. ReadmatchedCountandmodifiedCount. - Run the same update again. Confirm
matched: 1, modified: 0and explain it. - Update with a filter matching nothing. Confirm success with
matched: 0. - Use
replaceOnewith only{ className: "11th" }. Read the document afterwards and list what was lost. - Restore it and use
$setinstead. - Increment a fee balance with
$incusingNumberDecimal. Then do it by read-modify-write from two shells at once and compare. $unseta field, then query{ field: null }. Confirm it matches both absent and null.- Upsert a student twice with
$setOnInsert: { createdAt }. ConfirmcreatedAtdid not change on the second call. - Move
createdAtinto$setand repeat. Confirm it changed. - Run two concurrent upserts with no unique index and confirm the duplicate. Add the index and repeat.
- Hard-delete a student who has exam-result documents. Query the results and confirm they now reference nothing.
- Soft-delete instead. Then run a report query without the status filter and confirm the student reappears.
- Use
findOneAndUpdatewithreturnDocument: "after". - Query
_idwith a raw string, then withObjectId().
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
$setfor updates - Read
matchedCountandmodifiedCount - Test a filter with
findbefore updating or deleting - Recognise a duplicate key error
Review questions
- What is the difference between
matchedCountandmodifiedCount? - What does
replaceOnedo thatupdateOnewith$setdoes not? - Why does
$setOnInsertexist? - Why is a hard delete more dangerous in MongoDB than in SQL Server?
Next: Querying and filtering