MongoDB CRUD Tutorial: Insert, Find, Update, and Delete Documents
Learn MongoDB CRUD in the shell and Node.js: insertOne, find with operators, update operators, upserts, deletes, bulkWrite and write concern.
MongoDB CRUD looks simple on day one. Even so, most of the bugs I have fixed in MongoDB code still came from it. An update that wiped a document, a read-modify-write that lost concurrent changes, an array update that touched the wrong element. Here I walk through insert, find, update and delete in the mongo shell, then show the same thing in the Node.js driver, with the habits I would want any new engineer on my team to pick up early.
If you are new to documents and collections, start with What Is MongoDB? first. I use the same shop database throughout the series.
Setup for This MongoDB CRUD Tutorial
You need a MongoDB server and the mongo shell that ships with it. A local 4.2 install or a free Atlas M0 cluster both work. Everything here also runs on 4.0 except the pipeline update near the end. Connect and seed a few documents:
use shop
db.customers.insertMany([
{ _id: 1, name: "Asha Rao", email: "asha@example.com", city: "Pune", tier: "gold", points: 1200 },
{ _id: 2, name: "Vikram Shah", email: "vikram@example.com", city: "Mumbai", tier: "silver", points: 300 },
{ _id: 3, name: "Meera Iyer", email: "meera@example.com", city: "Pune", tier: "bronze", points: 40 }
])
db.orders.insertMany([
{ _id: 101, customerId: 1, status: "paid", total: NumberDecimal("1500.00"),
items: [ { sku: "BK-101", qty: 4, price: NumberDecimal("125.00") }, { sku: "HD-200", qty: 2, price: NumberDecimal("500.00") } ] },
{ _id: 102, customerId: 2, status: "pending", total: NumberDecimal("250.00"),
items: [ { sku: "BK-102", qty: 1, price: NumberDecimal("250.00") } ] },
{ _id: 103, customerId: 1, status: "shipped", total: NumberDecimal("3200.00"),
items: [ { sku: "HD-200", qty: 5, price: NumberDecimal("640.00") } ] }
])
I use small integer _id values here so the examples are easy to read. In an application I let the driver generate ObjectIds unless there is a natural unique key.
Create Operations in MongoDB CRUD: insertOne and insertMany
insertOne writes a single document and returns the _id it used:
db.customers.insertOne({ name: "Rahul Nair", email: "rahul@example.com", city: "Kochi", tier: "bronze", points: 0 })
{
"acknowledged" : true,
"insertedId" : ObjectId("5de4f2a1c3b2a1d4e5f60789")
}
insertMany takes an array. By default it is ordered, which means it stops at the first error and leaves everything before it in place. With ordered: false, MongoDB attempts every document and reports all the failures together. For bulk loads where some rows may be duplicates, unordered is usually what I want.
Duplicates only fail if a unique index exists, so let me add one and try:
db.customers.createIndex({ email: 1 }, { unique: true })
db.customers.insertMany([
{ name: "Asha R", email: "asha@example.com" },
{ name: "Neha Gupta", email: "neha@example.com" }
], { ordered: false })
BulkWriteError({
"writeErrors" : [
{
"index" : 0,
"code" : 11000,
"errmsg" : "E11000 duplicate key error collection: shop.customers index: email_1 dup key: { email: \"asha@example.com\" }",
...
}
],
"nInserted" : 1,
...
})
The E11000 message is worth learning to read. It names the collection, the index and the duplicate value, which is usually all you need to find the problem. Notice nInserted: 1: Neha was still inserted because the batch was unordered.
For loading thousands of documents from a file, I wrote a separate post on insertMany in MongoDB via file, so I will not repeat it here.
Read Operations in MongoDB CRUD: find and Query Operators
find takes a filter document and returns a cursor. Equality is just a field and value. Everything else uses operators:
db.customers.find({ city: "Pune", points: { $gte: 100 } })
db.orders.find({ status: { $in: ["paid", "shipped"] }, total: { $gt: NumberDecimal("1000") } })
db.customers.find({ $or: [ { tier: "gold" }, { points: { $lt: 50 } } ] }, { name: 1, _id: 0 })
One subtle point: { phone: null } matches documents where phone is null and documents where the field is missing entirely. If you need to tell those apart, use { phone: { $exists: false } } for missing fields, or { phone: { $type: "null" } } for explicit nulls. I have seen reporting queries double count because of this.
The comparison operators are $eq, $ne, $gt, $gte, $lt, $lte, $in and $nin. $exists checks whether a field is present at all. Multiple conditions in one filter document are an implicit AND. You only need $and when you repeat the same field or combine several $or clauses. The query operators reference lists the rest.
Querying arrays
Arrays catch people out. This query looks like it finds orders containing an HD-200 line with quantity over 3:
db.orders.find({ "items.sku": "HD-200", "items.qty": { $gt: 3 } }, { _id: 1 })
{ "_id" : 101 }
{ "_id" : 103 }
Order 101 should not be there. The query actually finds orders where some item is HD-200 and some item, possibly a different one, has quantity over 3. When both conditions must hold for the same element, use $elemMatch:
db.orders.find({ items: { $elemMatch: { sku: "HD-200", qty: { $gt: 3 } } } }, { _id: 1 })
{ "_id" : 103 }
Order 101 has HD-200 with quantity 2 and a different SKU with quantity 4. So it matched the first query by accident, and it correctly drops out of this one. $all matches arrays containing every listed value, and $size matches an exact array length.
Projection, sort, skip and limit
The second argument to find is a projection. Include fields with 1 or exclude them with 0, but do not mix the two except for _id:
db.customers.find({ city: "Pune" }, { name: 1, points: 1, _id: 0 }).sort({ points: -1 }).limit(2)
{ "name" : "Asha Rao", "points" : 1200 }
{ "name" : "Meera Iyer", "points" : 40 }
A cursor fetches results in batches rather than all at once. In application code that means you can stream large result sets without holding them all in memory. For pagination, skip works but gets slower as the offset grows because the server still walks past every skipped document. On deep pages I paginate on an indexed field instead, such as “give me the next 20 orders with _id greater than the last one I showed”.
For counting, use countDocuments(filter) when you need an exact count for a filter, and estimatedDocumentCount() when you want a fast approximate total for the whole collection from metadata. The drivers deprecated the old count() in their 4.0 compatible releases, so new code should use the two explicit methods.
Update: Operators, Not Replacements
This is the section I most want people to absorb. updateOne and updateMany change specific fields using update operators. replaceOne swaps the entire document, keeping only _id.
The older update() method would quietly replace the whole document if you forgot the $set. The newer updateOne refuses instead:
db.customers.updateOne({ _id: 2 }, { tier: "gold" })
Error: the update operation document must contain atomic operators
The correct version uses $set:
db.customers.updateOne({ _id: 2 }, { $set: { tier: "gold" }, $inc: { points: 50 } })
The operators I use most:
$setand$unsetto write or remove fields.$incto add to a number atomically, which is how stock counts and counters should change.$pushto append to an array, with$eachfor several values and$sliceto cap the length.$addToSetto append only if the value is not already present.$pullto remove matching elements.
Capping an array is a pattern I use constantly for “recently viewed” lists:
db.customers.updateOne(
{ _id: 1 },
{ $push: { recentViews: { $each: ["BK-101"], $position: 0, $slice: 5 } } }
)
The newest item goes to the front and the array never grows past five entries. As a result, the document size stays bounded.
Updating elements inside arrays
To update the element that matched the filter, use the positional $ operator. To update every element that meets a condition, use the filtered positional operator $[name] with arrayFilters:
// set the price of the HD-200 line in order 101
db.orders.updateOne({ _id: 101, "items.sku": "HD-200" }, { $set: { "items.$.price": NumberDecimal("520.00") } })
// flag every line with qty of 2 or more as bulk, across all orders
db.orders.updateMany(
{},
{ $set: { "items.$[line].bulk": true } },
{ arrayFilters: [ { "line.qty": { $gte: 2 } } ] }
)
The update operators reference covers the full set, including $min, $max and $currentDate.
Updating the first match in a given order
updateOne has no sort option. It updates whichever matching document the server reaches first. That depends on the index it picks and is not something to rely on. When I need “the oldest pending order” specifically, I use findOneAndUpdate with a sort, which I cover below, even when I do not need the returned document. The other approach is to make the filter precise enough that only one document can match: read the _id first, then update with both the _id and the expected status in the filter. If another process changed the status in between, the update matches nothing and modifiedCount comes back as 0. Your code can treat that as “someone else got there first”.
Upserts
An upsert updates a matching document or inserts one if nothing matches. $setOnInsert sets fields only on the insert path, which is perfect for creation timestamps:
db.dailyStats.updateOne(
{ day: "2019-12-02", sku: "BK-101" },
{ $inc: { sold: 1 }, $setOnInsert: { firstSaleAt: new Date() } },
{ upsert: true }
)
Back an upsert filter with a unique index. Without one, two concurrent upserts for the same key can both insert, and you end up with duplicates.
Updates with an aggregation pipeline
Since 4.2 an update can be an aggregation pipeline. That lets a field be computed from other fields in the same document. Here I recompute an order total from its items:
db.orders.updateOne(
{ _id: 101 },
[ { $set: { total: { $sum: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.price", "$$i.qty"] } } } } } } ]
)
db.orders.findOne({ _id: 101 }, { total: 1 })
{ "_id" : 101, "total" : NumberDecimal("1540.0000000000000000") }
The long run of zeros is Decimal128 keeping the full scale of the multiplication. Wrapping the sum in $round: [ <expression>, 2 ], another 4.2 addition, gives NumberDecimal("1540.00"). Either way it is one atomic write. No reading the document into the application, no race.
Delete Operations in MongoDB CRUD
deleteOne removes the first matching document and deleteMany removes all matches:
db.orders.deleteMany({ status: "cancelled", total: { $lt: NumberDecimal("100") } })
Run the same filter through find or countDocuments before running a deleteMany. It takes ten seconds and has saved me more than once.
In many systems I prefer a soft delete: set deletedAt with $set and filter it out of normal queries. It keeps an audit trail and makes undo possible. For data that should disappear on its own, such as sessions or one-time tokens, a TTL index lets the server delete documents after a set time:
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })
The TTL monitor runs in the background roughly every 60 seconds, so expiry is not instant. I cover TTL and other index options in the indexing tutorial.
findOneAndUpdate and Atomic Patterns
findOneAndUpdate modifies a document and returns it in a single atomic step. That makes it the right tool for claiming work from a queue, because two workers can never claim the same job:
db.jobs.insertMany([ { _id: "j1", status: "queued", createdAt: new Date() }, { _id: "j2", status: "queued", createdAt: new Date() } ])
db.jobs.findOneAndUpdate(
{ status: "queued" },
{ $set: { status: "running", worker: "worker-7", startedAt: new Date() } },
{ sort: { createdAt: 1 }, returnNewDocument: true }
)
returnNewDocument: true returns the document as it looks after the update. The default returns the original. The read-modify-write alternative, where you find a queued job and then updateOne it, lets two workers pick the same job between the read and the write.
Write Concern and bulkWrite
Every write accepts a write concern. w sets how many replica set members must acknowledge, j: true waits for the journal, and wtimeout stops waiting after a number of milliseconds (the write itself is not undone if it times out):
db.orders.insertOne(
{ _id: 104, customerId: 3, status: "paid", total: NumberDecimal("99.00"), items: [] },
{ writeConcern: { w: "majority", wtimeout: 5000 } }
)
bulkWrite sends a mixed batch of inserts, updates and deletes for one collection in a single request:
db.orders.bulkWrite([
{ insertOne: { document: { _id: 105, customerId: 2, status: "pending", total: NumberDecimal("80.00"), items: [] } } },
{ updateOne: { filter: { _id: 102 }, update: { $set: { status: "paid" } } } },
{ deleteOne: { filter: { _id: 104 } } }
], { ordered: false })
The same ordered and unordered rules apply as with insertMany. The bulk write operations page explains how the shell and drivers split a large batch into groups of the same operation type.
MongoDB CRUD in Node.js
The Node.js driver mirrors the shell closely. Create one MongoClient per process, connect once at startup and reuse it. A client per request is the most common performance bug I find in Node services.
const { MongoClient } = require("mongodb");
async function main() {
const client = await MongoClient.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
});
try {
const orders = client.db("shop").collection("orders");
const { insertedId } = await orders.insertOne({ customerId: 3, status: "pending", items: [] });
const pending = await orders.find({ status: "pending" }).project({ _id: 1, customerId: 1 }).toArray();
console.log(pending.length, "pending orders");
const result = await orders.findOneAndUpdate(
{ _id: insertedId },
{ $set: { status: "paid" } },
{ returnOriginal: false }
);
console.log(result.value.status); // "paid"
} finally {
await client.close();
}
}
main().catch(console.error);
In the 3.x driver, findOneAndUpdate resolves to a result object and the document sits in result.value, which is null if nothing matched. The option is returnOriginal: false rather than the shell’s returnNewDocument: true, a small inconsistency that trips people up when they move between the two. The useNewUrlParser and useUnifiedTopology flags opt into the new connection string parser and the new server discovery and monitoring engine. If you leave them out, the 3.3 and later drivers print deprecation warnings at startup. The Node.js driver documentation covers the options in full. If you are deciding whether to add Mongoose on top, I compare the two in Mongoose vs the native driver.
Which MongoDB CRUD Write Method Should You Use?
My rule of thumb:
- Changing a few fields:
updateOnewith$set,$incand friends. - You need the document back:
findOneAndUpdate. - Many mixed changes at once:
bulkWrite. - Replacing the whole document:
replaceOne, and rarely. If you find yourself reading a document, editing it in code and writing it back, you probably want operators.
Common MongoDB CRUD Mistakes
The one I have seen cause real damage is the read-modify-write: load a document, change it in application code, save the whole thing back. Two concurrent requests both read version one, and the second save silently discards the first. Update operators avoid that entirely because the change happens on the server in one atomic step.
Close behind are legacy shell methods. insert(), update() and remove() still turn up in old scripts and Stack Overflow answers. update() without the multi option changes only one document, and without operators it replaces the document. Use the explicit insertOne, updateMany and deleteMany family so the intent is obvious to the next reader.
Frequently Asked Questions
Why did my update replace the whole document?
You almost certainly used replaceOne, an ORM save method, or the legacy update() with a plain document instead of operators. The current updateOne and updateMany reject documents without operators, so switching to them and using $set fixes it.
How do I update an element inside an array?
Use the positional $ operator to update the element your filter matched, or $[name] with arrayFilters to update every element meeting a condition. $[] on its own updates all elements.
What is the difference between countDocuments and count?
countDocuments runs an aggregation and returns an exact count for a filter. count is deprecated and could return inaccurate results on sharded clusters or after an unclean shutdown. For a fast total of the whole collection, estimatedDocumentCount reads collection metadata instead.
Is there a limit on insertMany batch size?
Not one you need to manage yourself. The server accepts at most 100,000 writes per batch and limits message size, and the drivers and the shell split larger arrays into several batches automatically. Each document still has to fit within the 16 MiB limit, and if your array comes from a JSON file, the steps in my insertMany via file post apply unchanged.
Pick one read-modify-write in your codebase this week, the kind where a document is loaded, edited in code and saved back, and rewrite it as a single atomic update with operators. It is usually a small change that removes a race condition you may not know you have. It also gets the team thinking in MongoDB’s terms before you move on to schema design.
Last updated on 26 October 2026