Introduction
After five days into MongoDB fundamentals, you've learned about document structure and schema design. Now comes the practical reality: actually reading, writing, and deleting data at scale. CRUD operations in MongoDB are deceptively simple on the surface—insertOne, find, updateOne, deleteOne—but the patterns you choose today will either keep your application responsive or bury it under performance debt. In this article, we'll walk through each operation type, show you the patterns that work in production, and highlight the pitfalls we've seen team members (and ourselves) stumble into.
Insert — insertOne and insertMany
The insert operation is often the first thing a developer reaches for, and it's where many teams establish good or bad habits. MongoDB gives you two main methods: insertOne for a single document and insertMany for multiple documents at once.
Let's start with a real scenario. You're building an e-commerce platform and need to record a product:
const db = client.db('ecommerce');
const products = db.collection('products');
// Single insert
const result = await products.insertOne({
name: "Mug",
price: 12,
tags: ["kitchen"],
createdAt: new Date(),
inventory: 100
});
console.log(result.insertedId); // ObjectId automatically generated
MongoDB automatically generates an _id field if you don't provide one. That's convenient, but notice we're adding createdAt—this pattern is essential for auditing and sorting by insertion order.
Now imagine you're importing 10,000 products from a CSV. You could loop and call insertOne 10,000 times. Don't. Use insertMany instead:
const productsToInsert = [
{ name: "Mug", price: 12, tags: ["kitchen"], createdAt: new Date() },
{ name: "Plate", price: 8, tags: ["kitchen"], createdAt: new Date() },
{ name: "Fork", price: 2, tags: ["cutlery"], createdAt: new Date() }
// ... 9,997 more documents
];
const result = await products.insertMany(productsToInsert, { ordered: false });
console.log(`Inserted ${result.insertedCount} documents`);
The ordered: false flag is important here. By default, insertMany stops at the first error. If you're importing data and one document has a duplicate key, the entire operation halts. With ordered: false, MongoDB continues inserting valid documents and reports errors separately—better for bulk operations where a few failures are acceptable.
Find — Filters are documents too
Finding data is where most of your application logic will live. The find operation takes a filter (which is just a document) and returns a cursor—a lazy iterator over matching documents.
Here's the fundamental pattern:
const db = client.db('ecommerce');
const products = db.collection('products');
// Find products under $20
const results = await products
.find({ price: { $lt: 20 } })
.sort({ price: 1 })
.limit(10)
.toArray();
console.log(results);
Let's break this down. The filter { price: { $lt: 20 } } is a MongoDB query operator expression. $lt means "less than." You can chain operators:
// Find products between $5 and $20
const results = await products
.find({
price: { $gte: 5, $lte: 20 },
tags: { $in: ["kitchen", "cutlery"] }
})
.toArray();
The $in operator checks if the tags field contains any value in the array. This is powerful for e-commerce filters where you let users select multiple categories.
Now, a real-world scenario: your product listing page loads. You need pagination, sorting, and filtering. Here's the pattern:
async function searchProducts(filters, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const query = {};
if (filters.minPrice !== undefined) {
query.price = { ...query.price, $gte: filters.minPrice };
}
if (filters.maxPrice !== undefined) {
query.price = { ...query.price, $lte: filters.maxPrice };
}
if (filters.tags && filters.tags.length > 0) {
query.tags = { $in: filters.tags };
}
if (filters.search) {
query.name = { $regex: filters.search, $options: 'i' }; // case-insensitive
}
const results = await products
.find(query)
.sort({ price: 1 })
.skip(skip)
.limit(pageSize)
.toArray();
const total = await products.countDocuments(query);
return { results, total, page, pageSize };
}
Notice we build the query object conditionally. We also fetch the total count for pagination metadata. This is standard for UI pagination, but remember: countDocuments scans the entire collection unless the query is optimized by an index. For large datasets, consider caching the total or using approximate counts.
Update — Use operators like $set and $inc — never replace by accident
Updates are where we see the most production issues. MongoDB's update model is flexible, but flexibility without discipline causes data loss.
There are two update verbs: partial updates and replacements. Always use partial updates with operators in production.
Here's the pattern:
// GOOD: Partial update with $set
await products.updateOne(
{ _id: ObjectId("...") },
{ $set: { price: 15, lastModified: new Date() } }
);
// BAD: Full replacement (this wipes all other fields!)
await products.updateOne(
{ _id: ObjectId("...") },
{ price: 15, lastModified: new Date() }
);
See the difference? The bad example replaces the entire document with just those two fields. Your tags, inventory, createdAt—all gone. Use $set to update only the fields you intend to change.
Common operators for updates include:
$set— set a field to a value$inc— increment a numeric field$push— append to an array$pull— remove from an array$unset— remove a field$currentDate— set to current timestamp
A real scenario: customer places an order, and we need to decrement inventory and record the order:
async function processOrder(productId, quantity, orderId) {
// Atomic decrement of inventory
const result = await products.findOneAndUpdate(
{ _id: ObjectId(productId), inventory: { $gte: quantity } },
{
$inc: { inventory: -quantity },
$push: {
orders: {
orderId: ObjectId(orderId),
quantity,
date: new Date()
}
},
$set: { lastUpdated: new Date() }
},
{ returnDocument: 'after' }
);
if (!result.value) {
throw new Error('Insufficient inventory');
}
return result.value;
}
Notice findOneAndUpdate with returnDocument: 'after'. This returns the updated document, which is useful for confirming the update succeeded. Also notice the filter includes inventory: { $gte: quantity }—this is optimistic locking. If inventory is insufficient, the query matches zero documents and returns null. This prevents overselling.
For bulk updates, use updateMany:
// Apply a 10% discount to all products in the "clearance" category
await products.updateMany(
{ tags: { $in: ["clearance"] } },
{
$mul: { price: 0.9 }, // multiply price by 0.9
$set: { discountedAt: new Date() }
}
);
Delete — deleteOne and deleteMany with precise filters
Deletion is the irreversible operation. Take your time with filters here.
deleteOne removes a single document:
// Delete a specific product
await products.deleteOne({ _id: ObjectId("...") });
// Delete the first (oldest) product under $5
await products.deleteOne({ price: { $lt: 5 } });
deleteMany removes all matching documents:
// Delete all expired coupons
await coupons.deleteMany({ expiresAt: { $lt: new Date() } });
// Delete all products in a discontinued category
await products.deleteMany({ tags: { $in: ["discontinued"] } });
A production pattern we use: soft deletes. Instead of actually removing data, mark it as deleted:
// Instead of deleteOne(filter), do:
await products.updateOne(
filter,
{
$set: { deletedAt: new Date(), status: 'deleted' },
$unset: { publishedAt: 1 } // optionally remove publish timestamp
}
);
// When querying, always exclude deleted docs:
const activeProducts = await products
.find({ deletedAt: { $exists: false } })
.toArray();
Soft deletes buy you recovery time if a bug causes accidental bulk deletes. You can restore or audit what happened. It also preserves referential integrity—if a customer places an order referencing a product ID, hard-deleting the product severs that link.
Common mistakes
- Looping instead of batching. Calling
insertOnein a loop 10,000 times is 10,000 network round-trips. UseinsertMany. Same applies todeleteManyinstead of loopingdeleteOne. - Full document replacement on update. Omitting
$setsilently wipes fields. Make it a code review habit to flag updates without operators. - Unindexed queries on large collections. A
find({ tags: { $in: [...] } })on 10 million documents without an index ontagswill scan the entire collection. Always check execution plans withexplain(). - No filter in deleteMany. Accidentally calling
deleteMany({})deletes the entire collection. Use soft deletes as a safety net. - Ignoring write concern. By default, inserts acknowledge after the driver receives the ack from MongoDB, not after disk flush. For critical operations, consider
{ writeConcern: { w: 'majority' } }to wait for replication.
Key takeaways
- Use
insertManywithordered: falsefor bulk imports; always includecreatedAtfor auditability. - Build filters as objects and compose them conditionally; remember filters are documents too.
- Always use update operators (
$set,$inc, etc.) and never rely on full replacement in production code. - Prefer soft deletes (marking with
deletedAt) over hard deletes for recovery and referential integrity. - Index your query filters and always verify with
explain()` before considering a query "done." - Batch operations reduce network round-trips; a single
insertManybeats a loop ofinsertOneby orders of magnitude.






Comments
Loading comments…