Type something to search...
MongoDB CRUD Operations Explained with Practical Examples

MongoDB CRUD Operations Explained with Practical Examples

Almost everything an application does with a database boils down to four operations: create records, read them back, change them, and remove them. In SQL, that's INSERT, SELECT, UPDATE, and DELETE. In MongoDB, it's a family of collection methods like insertOne, find, updateMany, and deleteOne, each taking plain documents as arguments instead of a query language string.

The methods themselves are easy to learn. The part that takes practice is knowing which variant to use, what the filter and update documents actually do, and which defaults will surprise you. Calling updateOne without an operator, forgetting that find returns a cursor, or deleting everything with an empty filter are rites of passage you can skip.

This guide covers every core CRUD method in mongosh, with realistic examples on a small bookstore dataset, the results you should expect to see, the equivalent Node.js driver code, and the mistakes worth avoiding.

Setting Up the Example Data

All the examples use a books collection in a bookstore database. Open mongosh and run:

use bookstore
db.books.drop() // start clean if you've run this before

We'll create the data in the next section as part of learning inserts.

Create: Inserting Documents

insertOne

insertOne adds a single document and returns its _id:

db.books.insertOne({
  title: "The Pragmatic Programmer",
  author: "David Thomas",
  year: 1999,
  price: 39.99,
  stock: 12,
  tags: ["software", "career"],
});
{
  acknowledged: true,
  insertedId: ObjectId('66f8a1c2d3e4f5a6b7c8d9e0')
}

You didn't provide an _id, so one was generated. If you do provide one, MongoDB uses it, and inserting a second document with the same _id fails with a duplicate key error (code 11000).

insertMany

insertMany takes an array and inserts all of them in one round trip:

db.books.insertMany([
  {
    title: "Clean Code",
    author: "Robert C. Martin",
    year: 2008,
    price: 34.5,
    stock: 0,
    tags: ["software"],
  },
  {
    title: "Refactoring",
    author: "Martin Fowler",
    year: 2018,
    price: 47.0,
    stock: 5,
    tags: ["software", "design"],
  },
  {
    title: "Designing Data-Intensive Applications",
    author: "Martin Kleppmann",
    year: 2017,
    price: 52.0,
    stock: 8,
    tags: ["databases", "distributed"],
  },
  {
    title: "Dune",
    author: "Frank Herbert",
    year: 1965,
    price: 12.99,
    stock: 30,
    tags: ["fiction", "sci-fi"],
  },
  {
    title: "The Left Hand of Darkness",
    author: "Ursula K. Le Guin",
    year: 1969,
    price: 14.5,
    stock: 3,
    tags: ["fiction", "sci-fi"],
  },
]);
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('66f8a1c2d3e4f5a6b7c8d9e1'),
    '1': ObjectId('66f8a1c2d3e4f5a6b7c8d9e2'),
    ...
  }
}

By default, insertMany is ordered: documents are inserted in sequence, and if one fails (say, a duplicate _id), MongoDB stops and skips the rest. Pass { ordered: false } to keep going past errors and insert everything that can be inserted:

db.books.insertMany(docs, { ordered: false });

Unordered inserts are what you want for bulk imports where a few bad records shouldn't block the whole batch. For very large imports or mixed operations, bulkWrite gives you even more control.

Read: Finding Documents

find

find takes a filter document and returns every matching document. An empty filter matches everything:

db.books.find();
db.books.find({ author: "Frank Herbert" });

A filter with multiple fields means all of them must match (an implicit AND):

db.books.find({ tags: "fiction", year: 1969 });
[
  {
    _id: ObjectId('66f8a1c2d3e4f5a6b7c8d9e5'),
    title: 'The Left Hand of Darkness',
    author: 'Ursula K. Le Guin',
    year: 1969,
    price: 14.5,
    stock: 3,
    tags: [ 'fiction', 'sci-fi' ]
  }
]

Notice that tags: "fiction" matched a document where tags is an array containing "fiction". Equality on an array field matches if any element is equal, which is one of the most convenient behaviors in MongoDB.

Query Operators

For anything other than equality, use query operators, which start with $:

// Books under $20
db.books.find({ price: { $lt: 20 } });

// Published between 2000 and 2020
db.books.find({ year: { $gte: 2000, $lte: 2020 } });

// Written by either author
db.books.find({ author: { $in: ["Martin Fowler", "Martin Kleppmann"] } });

// Out of stock OR older than 1970
db.books.find({ $or: [{ stock: 0 }, { year: { $lt: 1970 } }] });

There are many more ($exists, $regex, $elemMatch, $expr, and others). They get their own deep dive in A Complete Guide to MongoDB Query Operators.

Projection, Sorting, Limiting

The second argument to find is a projection that controls which fields come back:

db.books.find({ tags: "software" }, { title: 1, price: 1, _id: 0 });
[
  { title: 'The Pragmatic Programmer', price: 39.99 },
  { title: 'Clean Code', price: 34.5 },
  { title: 'Refactoring', price: 47 }
]

find returns a cursor, and you chain methods onto it to shape the results:

db.books
  .find({}, { title: 1, price: 1, _id: 0 })
  .sort({ price: -1 })
  .skip(0)
  .limit(3);
[
  { title: 'Designing Data-Intensive Applications', price: 52 },
  { title: 'Refactoring', price: 47 },
  { title: 'The Pragmatic Programmer', price: 39.99 }
]

sort uses 1 for ascending and -1 for descending. The server always applies sort, then skip, then limit, regardless of the order you chain them.

findOne

findOne returns the first matching document directly (not a cursor), or null if nothing matches:

db.books.findOne({ title: "Dune" });
db.books.findOne({ title: "Missing Book" }); // null

Without a sort, "first" means whatever order the server finds documents in, which isn't guaranteed. If you need a specific document, filter on a unique field like _id.

Counting

Use countDocuments for an accurate count with a filter, and estimatedDocumentCount for a fast approximate total of the whole collection (it reads collection metadata instead of scanning):

db.books.countDocuments({ stock: { $gt: 0 } }); // 5
db.books.estimatedDocumentCount(); // 6

The old cursor count() method is deprecated; don't use it in new code.

Update: Modifying Documents

Update methods take two main arguments: a filter to choose documents, and an update document describing the change using update operators.

updateOne and updateMany

updateOne modifies the first matching document; updateMany modifies all of them.

// Change one book's price
db.books.updateOne({ title: "Clean Code" }, { $set: { price: 29.99 } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

The result tells you how many documents matched the filter and how many were actually changed. If the price was already 29.99, you'd see matchedCount: 1 and modifiedCount: 0, because nothing needed to change.

// Put every sci-fi book on a 10% discount
db.books.updateMany(
  { tags: "sci-fi" },
  { $mul: { price: 0.9 }, $set: { onSale: true } },
);

The Essential Update Operators

OperatorWhat it doesExample
$setSets a field's value (creates it if missing){ $set: { price: 19.99 } }
$unsetRemoves a field{ $unset: { onSale: "" } }
$incIncrements a number (negative to decrement){ $inc: { stock: -1 } }
$mulMultiplies a number{ $mul: { price: 1.05 } }
$pushAppends to an array{ $push: { tags: "classic" } }
$addToSetAppends only if the value isn't already there{ $addToSet: { tags: "classic" } }
$pullRemoves matching values from an array{ $pull: { tags: "career" } }
$currentDateSets a field to the current date{ $currentDate: { updatedAt: true } }

A realistic example: selling a copy of a book decrements stock and records when it happened, atomically, only if there's stock left:

db.books.updateOne(
  { title: "Refactoring", stock: { $gt: 0 } },
  { $inc: { stock: -1 }, $currentDate: { lastSoldAt: true } },
);

Because the stock check is part of the filter, two simultaneous purchases of the last copy can't both succeed. The second one simply matches nothing. Single-document updates in MongoDB are always atomic.

Upserts

Passing { upsert: true } tells MongoDB to insert a new document if nothing matches the filter:

db.books.updateOne(
  { title: "Neuromancer" },
  {
    $set: { author: "William Gibson", year: 1984, price: 15.0 },
    $setOnInsert: { stock: 0 },
  },
  { upsert: true },
);
{
  acknowledged: true,
  insertedId: ObjectId('66f8b2d3e4f5a6b7c8d9e0f1'),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}

The new document gets the equality fields from the filter (title) plus the $set fields. $setOnInsert only applies when a new document is created, which is perfect for defaults you don't want to overwrite on later updates.

replaceOne

replaceOne swaps the entire document (except _id) for a new one:

db.books.replaceOne(
  { title: "Neuromancer" },
  {
    title: "Neuromancer",
    author: "William Gibson",
    year: 1984,
    price: 15.0,
    stock: 4,
    tags: ["fiction", "cyberpunk"],
  },
);

Any field you leave out is gone. Use replaceOne when your application holds the complete, authoritative version of a document; use updateOne with operators for partial changes.

findOneAndUpdate

Sometimes you need the document back as part of the update, for example when claiming the next job from a queue. findOneAndUpdate does both atomically:

db.books.findOneAndUpdate(
  { title: "Dune" },
  { $inc: { stock: -1 } },
  { returnDocument: "after", projection: { title: 1, stock: 1 } },
);
{ _id: ObjectId('66f8a1c2d3e4f5a6b7c8d9e4'), title: 'Dune', stock: 29 }

returnDocument: "after" returns the modified document; the default, "before", returns the original.

Delete: Removing Documents

deleteOne and deleteMany

db.books.deleteOne({ title: "Neuromancer" });
{ acknowledged: true, deletedCount: 1 }
// Remove every out-of-stock book published before 2010
db.books.deleteMany({ stock: 0, year: { $lt: 2010 } });

Be very careful with deleteMany({}). An empty filter matches every document, so it empties the collection. If you really want to remove everything, db.books.drop() is faster and also removes indexes, but it's equally irreversible.

A good habit before any deleteMany or updateMany: run the same filter with countDocuments first and make sure the number matches your expectation.

db.books.countDocuments({ stock: 0, year: { $lt: 2010 } }); // 1, as expected

findOneAndDelete

Like its update counterpart, findOneAndDelete removes a document and returns it, which is handy for queue-style processing:

db.books.findOneAndDelete({ stock: 0 }, { sort: { year: 1 } });

Soft Deletes

Many applications never truly delete user-facing data. Instead they mark it:

db.books.updateOne(
  { title: "Refactoring" },
  { $set: { deletedAt: new Date() } },
);
db.books.find({ deletedAt: { $exists: false } });

This keeps an audit trail and makes "undo" possible, at the cost of adding the filter to every query. It's a trade-off worth considering early.

The Same Operations in Node.js

The official Node.js driver uses the same method names and the same filter and update documents, just with await:

import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI);
const books = client.db("bookstore").collection("books");

// Create
const { insertedId } = await books.insertOne({
  title: "Snow Crash",
  author: "Neal Stephenson",
  year: 1992,
  price: 16.0,
  stock: 7,
  tags: ["fiction", "cyberpunk"],
});

// Read
const cheapFiction = await books
  .find({ tags: "fiction", price: { $lt: 20 } })
  .project({ title: 1, price: 1 })
  .sort({ price: 1 })
  .toArray();

// Update
const updateResult = await books.updateOne(
  { _id: insertedId },
  { $inc: { stock: -1 } },
);
console.log(updateResult.modifiedCount); // 1

// Delete
await books.deleteOne({ _id: insertedId });

await client.close();

Two differences from the shell are worth noting. find returns a cursor, so you need toArray() or a for await loop to get documents. And filtering by _id requires an actual ObjectId instance: if you get an id as a string from a URL, convert it with new ObjectId(id) (imported from mongodb), or the filter won't match.

Write Concern in One Paragraph

Every write accepts a write concern that controls how durable the write must be before MongoDB acknowledges it. Modern drivers and Atlas default to w: "majority", meaning a majority of replica set members have the write, which is what you want almost always. You can override it per operation, for example { writeConcern: { w: 1 } } for fire-and-forget telemetry where speed matters more than durability. Leave the default alone unless you have a measured reason.

Common Mistakes

Updating without an operator. updateOne({ title: "Dune" }, { price: 10 }) throws an error in modern MongoDB because the update document must contain operators like $set. If you really want to overwrite the whole document, use replaceOne.

Forgetting that updateOne affects only one document. If your filter matches ten documents, updateOne changes exactly one of them (whichever it finds first). Use updateMany when you mean all.

Filtering _id with a string. { _id: "66f8a1c2d3e4f5a6b7c8d9e0" } doesn't match an ObjectId. Convert the string first.

Running deleteMany or updateMany with a mistaken filter. A typo in a field name makes the filter match nothing, which is harmless. An empty or overly broad filter matches everything, which isn't. Count first.

Reading a document, modifying it in code, and writing the whole thing back. Two concurrent requests will overwrite each other's changes. Use atomic update operators like $inc and $push so the server applies the change, and put preconditions in the filter.

Pushing to arrays without bounds. $push on every event makes documents grow forever. Use $push with $slice to cap the array, or move the data into its own collection.

Conclusion

MongoDB CRUD is a small, consistent API. Inserts use insertOne and insertMany. Reads use find and findOne, shaped with filters, projections, sort, and limit. Updates use updateOne, updateMany, and replaceOne with operators like $set, $inc, and $push, plus upsert when you want insert-or-update. Deletes use deleteOne and deleteMany, and the findOneAnd... variants return the affected document in the same atomic step.

For your next step, take the stock-decrement example (updateOne with stock: { $gt: 0 } in the filter and $inc in the update) and adapt it to something in your own application, like reserving a seat or claiming a coupon. It's the single most useful pattern in this guide.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading