MongoDB CRUD uses documents in collections: InsertOne/InsertMany (create), find/findOne (read), updateOne/updateMany with operators (update), deleteOne/deleteMany (delete).
The _id field is auto-generated as an ObjectId — capture it immediately after insert to avoid extra queries.
Update operators ($set, $inc, $push) prevent accidental document replacement; never pass a plain object as the update argument.
find() returns a cursor, not an array — always chain .toArray() or iterate; use .limit() on open-ended queries to protect production.
Soft-delete (isDeleted flag + deletedAt timestamp) is the production standard; physical deletion reserved for cleanup or GDPR erasure.
✦ Definition~90s read
What is MongoDB CRUD Operations?
MongoDB CRUD operations are the four fundamental actions you perform against a MongoDB database: Create, Read, Update, and Delete. They map directly to HTTP verbs (POST, GET, PUT/PATCH, DELETE) and are the building blocks of any application that persists data.
★
Imagine MongoDB is a giant filing cabinet where each drawer is a 'collection' and each folder inside is a 'document'.
Unlike SQL databases where you write declarative queries in a separate language, MongoDB CRUD operations are executed via driver methods in your application code—insertOne, find, updateOne, deleteMany, etc.—and operate on BSON documents. The critical distinction is that MongoDB's update operations are not atomic field replacements by default; if you call updateOne without an update operator like $set, you will replace the entire document with whatever object you pass, wiping all other fields.
This is a common footgun that has caused production data loss at scale, and understanding it is the difference between a safe mutation and a silent disaster.
In the broader ecosystem, MongoDB CRUD sits between raw database drivers and ORM/ODM layers like Mongoose or Prisma. You should use raw CRUD operations when you need fine-grained control over query performance, write concerns, or retry logic—especially in high-throughput systems where an ORM's abstraction overhead or unexpected behavior (like Mongoose's save() vs updateOne semantics) can introduce latency or bugs.
Avoid raw CRUD when you need complex transactional guarantees across multiple documents (use the session API with retryable writes) or when your team benefits from schema validation and type safety that an ODM provides. MongoDB's CRUD methods are also the foundation for aggregation pipelines and change streams, so mastering them directly—including the $set vs replacement distinction—is non-negotiable for any engineer working with MongoDB at scale.
Real-world impact: a single updateOne({_id: id}, {name: 'new'}) without $set will drop every other field in that document—embedded arrays, timestamps, nested objects, all gone. MongoDB's write concern defaults to {w: 1} (acknowledged by the primary), but without {j: true} (journaled), a crash after acknowledgment can still lose the write.
For critical data, you pair {w: 'majority', j: true} with retry logic using RetryableWrites and ReadConcern.majority to prevent phantom reads. These aren't academic concerns; they're the difference between a resilient system and a pager at 3 AM.
Plain-English First
Imagine MongoDB is a giant filing cabinet where each drawer is a 'collection' and each folder inside is a 'document'. CRUD is just the four things you'd ever do to that cabinet: drop in a new folder (Create), read what's inside one (Read), scribble changes on it (Update), or shred it (Delete). That's it — every database operation in existence boils down to these four actions.
Every application that stores data — whether it's a social media feed, an e-commerce cart, or a hospital records system — needs to talk to a database. MongoDB has become the go-to choice for teams building flexible, fast-moving products because it stores data as JSON-like documents instead of rigid rows and columns. Knowing how to speak its language isn't optional; it's the difference between an app that ships and one that stalls.
What MongoDB CRUD Operations Actually Do
CRUD stands for Create, Read, Update, Delete — the four fundamental operations for persisting and retrieving data. In MongoDB, these map to insertOne/insertMany, find, updateOne/updateMany/replaceOne, and deleteOne/deleteMany. The core mechanic: each operation targets a single document or a batch, using a filter to select documents and a modifier to specify changes. Unlike SQL, MongoDB updates are atomic at the document level — no multi-document transactions by default.
Key properties: updateOne without $set replaces the entire matched document with the new document you pass. This is a common trap — if you only want to change a single field, omitting $set wipes all other fields. The operation is O(log n) per document due to B-tree index traversal on the filter, but the write itself is O(1) for the document size. MongoDB uses a write-ahead journal for durability, and by default, write concern is "acknowledged" — the driver waits for the primary to confirm.
Use updateOne when you need to modify a single document by a unique identifier (e.g., _id). It's ideal for real-time updates like incrementing a counter, setting a status, or patching a field. In production, always pair updateOne with $set unless you explicitly intend a full replacement. The difference between a partial update and a full document wipe is just two characters — and that mistake has taken down production systems.
Missing $Set = Data Loss
updateOne({_id: id}, {field: value}) replaces the entire document with only {field: value}. Always use $set unless you mean to replace.
Production Insight
A team used updateOne without $set to update a user's email field, wiping their entire profile including hashed password and roles.
Symptom: users couldn't log in, and support tickets flooded in within minutes of deployment.
Rule: always wrap field updates in $set — treat updateOne as a partial update by default, not a replacement.
Key Takeaway
updateOne without $set replaces the whole document — never forget the $set.
Use findOneAndUpdate with returnDocument: 'after' when you need the updated document back.
Always test updates against a document with all fields to confirm no accidental wipe.
thecodeforge.io
MongoDB CRUD: updateOne Without $Set Data Wipe
Mongodb Crud Operations
Create — Inserting Documents the Right Way
Inserting data sounds trivial until you're doing it wrong in production. MongoDB gives you two insertion methods: insertOne() for a single document and insertMany() for a batch. The key insight most tutorials skip is what MongoDB hands back after an insert — an acknowledgement object containing the auto-generated _id. That _id is a 12-byte ObjectId, globally unique by design, and you should be capturing it in your application logic rather than ignoring it.
Why does this matter? Because in a typical e-commerce flow you insert an order document, then immediately need that order's _id to create a shipment record that references it. Ignoring the return value forces a second round-trip to the database just to find what MongoDB already told you.
insertMany() is more nuanced. By default it's ordered, meaning if document number 3 of 10 fails validation, documents 1 and 2 are already committed and 4-10 are abandoned. Passing the option { ordered: false } lets MongoDB push through all valid documents and collect errors at the end — a much better pattern for bulk imports where one bad record shouldn't kill the whole batch.
insertOrders.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// Connect to a local MongoDB instance using the official Node.js driverconst { MongoClient, ObjectId } = require('mongodb');
asyncfunctioninsertOrders() {
const client = new MongoClient('mongodb://localhost:27017');try {
await client.connect();
const db = client.db('storefront'); // target database
const ordersCollection = db.collection('orders'); // target collection// --- insertOne: single document ---// MongoDB auto-generates _id if you don't provide oneconst singleInsertResult = await ordersCollection.insertOne({
customerId: 'cust_8821',
items: [
{ productSku: 'SHOE-RED-42', quantity: 1, unitPrice: 79.99 },
{ productSku: 'LACE-BLK', quantity: 2, unitPrice: 3.50 }
],
orderStatus: 'pending',
createdAt: new Date() // always store dates as Date objects, not strings
});
// insertedId is the ObjectId MongoDB assigned — save this, don't query for it again
console.log('New order ID:', singleInsertResult.insertedId);
// --- insertMany: bulk insert with ordered:false so one bad doc doesn't abort the rest ---const bulkOrders = [
{
customerId: 'cust_1103',
items: [{ productSku: 'HAT-GRN-M', quantity: 1, unitPrice: 24.00 }],
orderStatus: 'pending',
createdAt: newDate()
},
{
customerId: 'cust_4477',
items: [{ productSku: 'BELT-BRN-L', quantity: 1, unitPrice: 34.50 }],
orderStatus: 'pending',
createdAt: newDate()
}
];
const bulkInsertResult = await ordersCollection.insertMany(bulkOrders, {
ordered: false // continue inserting even if one document fails validation
});
// insertedCount tells you how many actually landed
console.log('Orders inserted:', bulkInsertResult.insertedCount);
console.log('Inserted IDs:', bulkInsertResult.insertedIds);
} finally {
await client.close(); // always close the connection
}
}
insertOrders().catch(console.error);
After insertOne(), grab result.insertedId right there in the same function. Every extra database round-trip to 'find' the document you just created is wasted latency you already paid for.
Production Insight
Inserting into a shard cluster with a monotonically increasing _id (like a timestamp) can cause hotspotting on one shard. Use a random or hash-based shard key.
Always handle duplicate key errors (E11000) in your code — they're not bugs, they're design invariants.
Rule: choose _id generation strategy based on your write pattern.
Key Takeaway
Capture insertedId immediately after insertOne().
Use {ordered:false} for bulk inserts when one failure shouldn't abort the batch.
Never ignore the result object from insert operations.
Read — Querying Documents Without Killing Your Database
Reading data is where most MongoDB performance problems are born. find() returns a cursor, not an array — meaning MongoDB streams results lazily rather than loading everything into memory at once. This is a feature, not a quirk, and it matters the moment your collection grows past a few thousand documents.
The filter argument is where the real power lives. MongoDB's query language is composable: you can filter by exact match, range ($gte, $lte), array membership ($in), logical operators ($and, $or), and even run regex searches — all within a single query object. But power without discipline is dangerous. Running find({}) on a million-document collection with no limit() is how you bring a production server to its knees.
Projection is the query-level equivalent of SELECT in SQL — it tells MongoDB which fields to return. Always use it. Fetching a 40-field customer document when your UI only needs name and email wastes bandwidth, serialization time, and memory on both sides of the wire.
Indexes are what make reads fast, but that's a separate topic. The habit to build now is: every field you filter or sort on should eventually have an index behind it. Use explain('executionStats') on any query you care about to see whether MongoDB is doing a full collection scan (bad) or an index scan (good).
queryOrders.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const { MongoClient } = require('mongodb');
asyncfunctionqueryOrders() {
const client = new MongoClient('mongodb://localhost:27017');try {
await client.connect();
const ordersCollection = client.db('storefront').collection('orders');
// --- findOne: grab the first match, returns a plain object (not a cursor) ---const latestPendingOrder = await ordersCollection.findOne(
{ orderStatus: 'pending' }, // filter: only pending orders
{
projection: { customerId: 1, items: 1, createdAt: 1, _id: 0 }, // only return these fields
sort: { createdAt: -1 } // newest first
}
);
console.log('Latest pending order:', latestPendingOrder);
// --- find with range filter: orders created in the last 7 days ---const sevenDaysAgo = newDate(Date.now() - 7 * 24 * 60 * 60 * 1000);
const recentOrders = await ordersCollection
.find(
{
createdAt: { $gte: sevenDaysAgo }, // $gte = greater than or equal to
orderStatus: { $in: ['pending', 'processing'] } // $in matches any value in the array
},
{
projection: { customerId: 1, orderStatus: 1, createdAt: 1 }
}
)
.sort({ createdAt: -1 })
.limit(25) // ALWAYS limit open-ended queries in production
.toArray(); // materialise the cursor into an array
console.log(`Found ${recentOrders.length} recent orders`);
recentOrders.forEach(order => {
console.log(` ${order.customerId} — ${order.orderStatus} — ${order.createdAt.toISOString()}`);
});
// --- countDocuments: how many total pending orders exist? ---// Use countDocuments() NOT count() — count() is deprecated and ignores filters in some edge casesconst pendingTotal = await ordersCollection.countDocuments({ orderStatus: 'pending' });
console.log('Total pending orders:', pendingTotal);
} finally {
await client.close();
}
}
queryOrders().catch(console.error);
find() returns a Cursor object. Logging it directly prints cursor metadata, not your documents. Always chain .toArray() or iterate with for await...of. Forgetting this is a rite of passage — and a runtime bug that's surprisingly hard to spot.
Production Insight
A find() with no .limit() can kill your application and database under load. Always paginate or limit.
Missing indexes on filter fields lead to full collection scans; use explain() to verify.
Rule: every query filter and sort field that runs more than once a day needs an index.
Key Takeaway
find() returns a lazy Cursor — materialise with .toArray() or iterate.
Always use projection to fetch only needed fields.
Add .limit() to every open-ended query before it hits production traffic.
Update — Changing Data Without Replacing It
Updating is where MongoDB beginners most often shoot themselves. The critical rule: always use an update operator like $set, $inc, or $push. If you pass a plain document as the second argument to updateOne(), MongoDB treats it as a full replacement and wipes every field not in your update object. That's a legal operation, but it's almost never what you want.
MongoDB's update operators are surgical. $set modifies only the fields you name, leaving everything else untouched. $inc atomically increments a number — perfect for view counters or inventory tracking without a read-modify-write cycle. $push appends to an array, and $pull removes from one. $unset deletes a field entirely.
The upsert option is powerful but underused. Setting { upsert: true } tells MongoDB: if the filter matches something, update it; if nothing matches, create a new document. This collapses a common 'find-then-insert-or-update' pattern into a single atomic operation — no race conditions, no extra round-trips.
For bulk changes, updateMany() applies your update to every document matching the filter. Just make sure your filter is tight. Running updateMany({}, { $set: { archived: true } }) marks every single document in the collection as archived — no confirmation prompt, no undo.
updateOrders.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const { MongoClient, ObjectId } = require('mongodb');
asyncfunctionupdateOrders() {
const client = new MongoClient('mongodb://localhost:27017');try {
await client.connect();
const ordersCollection = client.db('storefront').collection('orders');
// --- updateOne with $set: change a single order's status ---// ALWAYS use $set — without it, your whole document gets replacedconst targetOrderId = newObjectId('64f3a2b1c9e77f001a3d8e12');
const statusUpdateResult = await ordersCollection.updateOne(
{ _id: targetOrderId }, // filter: match by exact _id
{
$set: {
orderStatus: 'dispatched',
dispatchedAt: new Date() // add a new field on the fly — no schema migration needed
}
}
);
console.log('Documents matched:', statusUpdateResult.matchedCount); // how many matched the filter
console.log('Documents modified:', statusUpdateResult.modifiedCount); // how many were actually changed// --- $inc: atomically decrement stock without a read-modify-write ---// This is safe under concurrent writes; plain read+write is NOTconst inventoryCollection = client.db('storefront').collection('inventory');
await inventoryCollection.updateOne(
{ productSku: 'SHOE-RED-42' },
{ $inc: { stockCount: -1 } } // subtract 1 from stockCount atomically
);
// --- upsert: create a shipment record if it doesn't exist, update if it does ---const shipmentResult = await client.db('storefront').collection('shipments').updateOne(
{ orderId: targetOrderId }, // filter: does a shipment for this order exist?
{
$set: {
orderId: targetOrderId,
carrier: 'FastFreight',
trackingNumber: 'FF-993821-XZ',
estimatedDelivery: newDate('2024-09-05')
}
},
{ upsert: true } // create it if it doesn't exist
);
// upsertedId is non-null only when a new document was createdif (shipmentResult.upsertedId) {
console.log('Shipment record created with ID:', shipmentResult.upsertedId);
} else {
console.log('Existing shipment record updated');
}
// --- updateMany: mark all orders older than 30 days as 'archived' ---const thirtyDaysAgo = newDate(Date.now() - 30 * 24 * 60 * 60 * 1000);
const archiveResult = await ordersCollection.updateMany(
{ createdAt: { $lt: thirtyDaysAgo }, orderStatus: 'dispatched' }, // tight filter!
{ $set: { orderStatus: 'archived', archivedAt: newDate() } }
);
console.log(`Archived ${archiveResult.modifiedCount} old orders`);
} finally {
await client.close();
}
}
updateOrders().catch(console.error);
Output
Documents matched: 1
Documents modified: 1
Shipment record created with ID: 64f3a2b1c9e77f001a3d9f01
Archived 0 old orders
Watch Out: Update Without $set Replaces the Document
updateOne({ _id: id }, { orderStatus: 'dispatched' }) doesn't add a field — it replaces the entire document with { orderStatus: 'dispatched' }. You'll lose every other field silently. Always wrap your changes in { $set: { ... } }.
Production Insight
Forgetting $set is the leading cause of accidental data loss in MongoDB. It's silent — no error, just missing data.
$inc is atomic; separate read-modify-write is not safe under concurrency.
Rule: if you need to update multiple documents with different values, consider bulkWrite with multiple updateOne statements.
Key Takeaway
Always wrap update fields in an operator: $set, $inc, $push, etc.
Use upsert:true to avoid separate find-then-insert race conditions.
Check matchedCount and modifiedCount to confirm the update had intended effect.
Delete — Removing Data Safely and Intentionally
Deletion in MongoDB is permanent and instantaneous. There's no recycle bin, no soft-delete built in, and no ROLLBACK. This is why production teams almost universally implement soft-deletes — adding a deletedAt timestamp field and filtering it out of queries — rather than physically removing documents. Physical deletion is reserved for true cleanup jobs like purging GDPR-expired data or clearing test fixtures.
MongoDB gives you deleteOne() for surgical removal of a single document and deleteMany() for bulk removal. The same golden rule from updates applies: if your filter is too broad, you will delete more than you intended. Always test your filter with a find() call first — confirm the count and spot-check a few returned documents before converting it to a deleteMany().
findOneAndDelete() is the atomic 'grab it and kill it' operation. It deletes the document and returns it to your application in a single server-side operation. This is exactly what you need for job queue patterns where a worker claims a task — using separate find() then deleteOne() calls creates a race condition where two workers could claim the same job.
Never run deleteMany({}) in production without a filter. There's no faster way to have a very bad day.
deleteOrders.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
const { MongoClient, ObjectId } = require('mongodb');
asyncfunctiondeleteOrders() {
const client = new MongoClient('mongodb://localhost:27017');try {
await client.connect();
const db = client.db('storefront');
const ordersCollection = db.collection('orders');
// --- PATTERN 1: Soft delete (recommended for business data) ---// Don't physically remove — mark as deleted so you keep the audit trailconst orderToSoftDelete = newObjectId('64f3a2b1c9e77f001a3d8e13');
await ordersCollection.updateOne(
{ _id: orderToSoftDelete },
{
$set: {
isDeleted: true,
deletedAt: new Date() // lets you audit WHEN it was deleted and query "deleted in last 30 days"
}
}
);
console.log('Order soft-deleted (record preserved for audit)');
// --- PATTERN 2: Hard delete with deleteOne ---// Appropriate for test data, temp records, or GDPR erasure requestsconst tempOrderId = newObjectId('64f3a2b1c9e77f001a3d8e14');
const hardDeleteResult = await ordersCollection.deleteOne({ _id: tempOrderId });
console.log('Hard-deleted document count:', hardDeleteResult.deletedCount);
// deletedCount will be 0 if the _id didn't exist — not an error, just a miss// --- PATTERN 3: findOneAndDelete — atomic claim-and-remove for job queues ---const jobsCollection = db.collection('pendingEmailJobs');
// Grab the highest-priority job AND remove it in one atomic step// If two workers call this simultaneously, only one gets the documentconst claimedJob = await jobsCollection.findOneAndDelete(
{ status: 'queued' },
{
sort: { priority: -1, queuedAt: 1 }, // highest priority, then FIFO
returnDocument: 'before' // return the document as it was before deletion
}
);
if (claimedJob) {
console.log('Worker claimed job:', claimedJob.jobId, '| Recipient:', claimedJob.recipientEmail);
} else {
console.log('No jobs in queue right now');
}
// --- PATTERN 4: deleteMany for bulk cleanup (always preview first!) ---// Step 1: preview — what would get deleted?const toDeleteCount = await ordersCollection.countDocuments({
isDeleted: true,
deletedAt: { $lt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) } // older than 90 days
});
console.log(`About to permanently purge ${toDeleteCount} soft-deleted orders...`);
// Step 2: execute only when you're sureconst purgeResult = await ordersCollection.deleteMany({
isDeleted: true,
deletedAt: { $lt: newDate(Date.now() - 90 * 24 * 60 * 60 * 1000) }
});
console.log('Purged:', purgeResult.deletedCount, 'old orders');
} finally {
await client.close();
}
}
deleteOrders().catch(console.error);
Output
Order soft-deleted (record preserved for audit)
Hard-deleted document count: 1
No jobs in queue right now
About to permanently purge 0 soft-deleted orders...
Purged: 0 old orders
Pro Tip: Preview Before deleteMany
Run countDocuments() with the exact same filter before any deleteMany() call. It's a two-second habit that has saved entire collections from accidental wipes. Treat deleteMany like a controlled demolition — measure twice, delete once.
Production Insight
Physical deletes are permanent; there is no rollback without a backup. Soft-deletes give you a recovery window.
findOneAndDelete is the only safe way to implement job queues — separate find and delete creates races.
Rule: preview your filter with countDocuments before any deleteMany.
Key Takeaway
Use soft-deletes for business-critical data.
Use findOneAndDelete for atomic claim-and-remove.
Always test the filter on a find before running deleteMany.
Write Concerns, Error Handling, and Retry Logic
Production applications need to decide how durable their writes are. MongoDB's writeConcern setting controls the level of acknowledgment: w:1 (acknowledge from primary only), w:majority (ack from replica set majority), or j:true (journaled). Each level trades latency for durability. If you absolutely cannot lose a write — say a payment confirmation — use w:majority and j:true. For logs or transient data, w:1 is fine.
Errors happen. Duplicate key errors (E11000) are thrown when a unique index constraint is violated. Your code must catch them. Network timeouts and writeConcern timeouts are distinct: the former means the driver couldn't reach the server, the latter means the server couldn't gather enough acknowledgments in time. Both require retry logic. The MongoDB driver provides built-in retryable writes for network errors, but not for writeConcern errors. You'll need to implement exponential backoff yourself.
A robust write pattern: attempt the write, catch errors, inspect the error label to distinguish transient from permanent, retry transient errors with backoff, and log permanent errors for later investigation. Never swallow errors — a failed write that goes unnoticed becomes a silent data loss.
writeWithRetry.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const { MongoClient } = require('mongodb');
asyncfunctioninsertWithRetry(collection, doc, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Set high writeConcern for critical dataconst result = await collection.insertOne(doc, {
writeConcern: { w: 'majority', j: true }
});
return result;
} catch (err) {
// Differentiate transient vs permanentif (err.code === 11000) {
// Duplicate key — permanent, log and abort
console.error('Duplicate key for doc:', JSON.stringify(doc));
throw err; // Re-throw for caller to handle
}
if (err.hasErrorLabel('TransientTransactionError') || err.message.includes('timeout')) {
// Transient — retry with backoffif (attempt === maxRetries) {
console.error('Max retries reached, write failed');
throw err;
}
const delay = Math.pow(2, attempt) * 100; // 200ms, 400ms, 800ms
console.log(`Retrying write, attempt ${attempt + 1} after ${delay}ms`);
awaitnewPromise(resolve => setTimeout(resolve, delay));
} else {
// Other permanent errorthrow err;
}
}
}
}
Beware the Silent WriteConcern Timeout
A writeConcern timeout does not mean the write failed — it means the acknowledged count wasn't confirmed in time. The write may still succeed on the primary. Use retry logic with idempotent operations to avoid duplicate inserts on timeout.
Production Insight
Read-preference and writeConcern decisions affect both consistency and latency; test under production-like load.
Retryable writes from the driver handle only network errors — not writeConcern timeouts or duplicate keys.
Rule: implement your own retry with exponential backoff for custom error types.
Key Takeaway
Choose writeConcern based on data criticality: w:majority for payments, w:1 for logs.
Retry transient errors with backoff; log permanent errors without re-throwing silently.
Indexing Strategies That Actually Matter For Read Performance
You've written a find() query. It works. Great. Now run it against a collection with 10 million documents and watch your application fall over. This isn't a bug — it's physics. MongoDB scans every document without an index. That's a collection scan. In production, that means timeout errors and angry users.
The WHY: Indexes are B-tree data structures that map field values to document locations. Without them, MongoDB has no shortcut to find your data. It reads every document, checks the filter, and discards what doesn't match. Slow reads are almost always missing indexes.
The HOW: Create indexes on fields you filter, sort, or join on. Use createIndex({ status: 1 }) for equality filters. For range queries or sorts, compound indexes like { status: 1, created_at: -1 } serve both conditions. Use explain() to verify query plans. Never guess — measure.
Senior shortcut: Drop indexes on high-write collections if they slow writes too much. Write-heavy systems need fewer indexes. Read-heavy systems need more. Balance is everything.
IndexAudit.sqlSQL
1
2
3
4
5
6
7
8
9
10
// io.thecodeforge — database tutorial
// Check query execution plan — spot collection scans
// This finds orders for user 8472 placed after Jan12024
db.orders.find({ user_id: 8472, created_at: { $gte: ISODate("2024-01-01") } }).explain("executionStats");
// Output will show stage: "COLLSCAN" if no index — add one:
db.orders.createIndex({ user_id: 1, created_at: -1 });
// Verify again — stage becomes "IXSCAN", totalDocsExamined drops from 2M to 50
Output
{
"executionStats": {
"executionSuccess": true,
"nReturned": 50,
"totalDocsExamined": 2000000,
"executionStages": {
"stage": "COLLSCAN",
"timeCreatedAt": "2024-03-15T10:30:00Z"
}
}
}
Production Trap:
Creating an index on a live production collection blocks all reads and writes. Schedule index builds in maintenance windows or use createIndex({...}, { background: true }) in older versions. In MongoDB 4.2+, background builds are default — but still test first.
Key Takeaway
Every query without an index is a table scan in disguise. Run explain() before you deploy.
Transactions — When CRUD Alone Isn't Enough
Your bank transfer updates account A, then account B. Power failure halfway through. Account A is empty, account B never got the money. Congratulations — you've lost customer trust and violated atomicity. Single-document operations in MongoDB are atomic by default. Multi-document operations are not. That's where transactions come in.
The WHY: Transactions give you ACID guarantees across multiple documents or collections. They're critical for financial systems, inventory management, or any operation where partial updates cause corruption. MongoDB supports multi-document transactions since version 4.0.
The HOW: Use startSession() and withTransaction(). Keep transactions short — they hold locks and impact performance. Never do heavy writes or network calls inside a transaction. If a transaction fails, catch the error and implement retry logic. Your callback should be idempotent — running it twice should be safe.
Real talk: Don't use transactions as a crutch for bad schema design. If you need frequent transactions, you might have a relational data model in a document database. Consider embedding related data or rethinking your schema first.
Transfer failed: MongoError: Transaction 1 has been aborted
Senior Shortcut:
Always check balance atomically with $gte condition inside the transfer update. If balance is insufficient, the update matches zero documents and you abort — no race condition possible.
Key Takeaway
Use transactions only when multiple documents must change together. For single-document updates, MongoDB's atomic operations are faster and simpler.
Best Practices for Beginners — Stop Writing Naive Queries
Most CRUD failures come from ignoring the database's temperament. MongoDB is not MySQL with JSON. It's a document store that punishes you for treating it like a relational database. The first rule: design your schema for the read patterns, not the write convenience. A denormalized document that saves one join is worth a thousand normalized ones that require $lookup.
Always use write concerns. The default acknowledges the primary only. That's fine for logs, suicidal for billing. Set w:majority on anything that matters. For reads, avoid find() without filters on large collections. That's a full collection scan masquerading as a query. Index your filter fields before you write the second document.
Error handling is not optional. Every write can fail — network blips, duplicate keys, document size limits. Wrap your operations with retry logic using a bounded exponential backoff. MongoDB drivers handle transient errors for you, but only if you bother reading the docs. Three retries, cap at 5 seconds, log every failure.
BestPracticesExample.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — database tutorial
-- Safe insert with write concern and error handlingCREATEPROCEDUREInsertOrder(
IN orderId VARCHAR(36),
IN customerId VARCHAR(36),
IN total DECIMAL(10,2)
)
BEGINDECLAREEXITHANDLERFORSQLEXCEPTIONBEGIN-- Log failure, retry logic lives in app layerINSERTINTOfailed_operations (operation, data, attempted_at)
VALUES ('InsertOrder', CONCAT(orderId, ',', customerId, ',', total), NOW());
RESIGNAL;
END;
INSERTINTOorders (_id, customer_id, total, status)
VALUES (orderId, customerId, total, 'pending');
-- Force majority acknowledged writeSETSESSION wsrep_sync_wait = 1;
END;
Output
Query OK, 1 row affected (0.003 sec)
Failed operation logged on error.
No silent failures.
Production Trap:
Default write concern is 'acknowledged' — the driver confirms the primary got it, not that it was replicated. One primary crash, and your 'successful' writes vanish. Always use w:majority for critical data.
Key Takeaway
Design your schema for reads, index before inserting, and never trust default write concerns.
Common Challenges and Solutions — The Stuff That Actually Breaks
The most frequent failure in production: duplicate key errors on upserts. You run an updateOne with upsert: true, two app instances fire at the same millisecond, and MongoDB throws E11000. The fix? Use a unique compound index on the fields that define the document identity. If you still hit conflicts, move to a deterministic _id generation — UUIDs aren't just for primary keys.
Second place: document size limits. MongoDB maxes out at 16MB per document. That's generous until you embed an array of comments that grows unbounded. The solution is bucketing. Store time-series data in pre-defined chunks (one document per hour, per user). When a bucket hits 5000 entries, split or archive. Check Object.bsonsize() in your application code before writes.
Third: reading stale data after a write. Replica sets replicate asynchronously. If your app reads from a secondary, it might see an older version. Primary reads are consistent. Secondary reads are fast but eventually consistent. Know the trade-off. For financial data, always read from the primary. For analytics, hit the secondaries.
CommonChallengesSolution.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — database tutorial
-- Handle duplicate key gracefully with retryCREATEPROCEDUREInsertOrUpdateOrder(
IN orderId VARCHAR(36),
IN customerId VARCHAR(36),
IN total DECIMAL(10,2)
)
BEGINDECLAREEXITHANDLERFOR1062-- Duplicate key errorBEGIN-- Update existing recordUPDATE orders
SET total = total + VALUES(total)
WHERE _id = orderId;
END;
INSERTINTOorders (_id, customer_id, total, status)
VALUES (orderId, customerId, total, 'pending')
ONDUPLICATEKEYUPDATE
total = total + VALUES(total);
END;
Output
Query OK, 1 row affected (0.002 sec)
Duplicate key handled — existing total incremented.
No crash, no lost data.
Senior Shortcut:
Always run db.collection.stats() on collections that might hit 16MB. It shows average document size. If any document exceeds 8MB, refactor your schema before it breaks in production.
Key Takeaway
Duplicate keys, document size limits, and eventual consistency are the three dragons. Kill them with compound indexes, bucketing, and primary reads.
Installation — Stop Guessing Which Driver and Version to Use
MongoDB CRUD doesn't work without a properly installed driver. The wrong driver version silently breaks queries, timeouts, and connection pools. For Node.js, install the official MongoDB driver, not the deprecated mongodb wrapper. Use npm install mongodb@6 — version 6 drops callback hell for native promises and unified topology. Python users need pymongo with dnspython for SRV connections — pip install pymongo[srv]. Java demands the synchronous driver (mongodb-driver-sync), not the old async. Always verify connectivity with a ping command after installation. Connection strings must escape special characters in passwords. MongoDB drivers don't warn you about hostname resolution failures—they just hang. Install once, test twice. A failed installation means every CRUD operation silently fails later.
Using the wrong driver version (e.g., mongodb@5 vs @6) silently drops replica set awareness. Always match driver version to your MongoDB server 5.x or 6.x.
Key Takeaway
Install the official driver matching your MongoDB version, then verify with a ping before writing any CRUD code.
Alternative: MongoDB Compass — When You Need to See Your Data Fast
Compass is the GUI that strips away query guesswork. Instead of writing a find filter blind, you visually inspect documents, build aggregation pipelines with drag-and-drop, and test indexes by running explain plans on real data. Compass excels at debugging: open a collection, sort by size, spot an unexpectedly large field, and kill it with a targeted delete. It also validates your connection strings without writing one line of code. But Compass is read-heavy — never use it to bulk update or delete in production. Its real power is schema analysis: the Schema tab shows field types, missing fields, and value distributions. Use Compass to profile before writing CRUD, then write your code. The tool is free and ships with MongoDB Community.
CompassAggregationTest.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — database tutorial
// Example aggregation built in Compass (then exported)
[{
$match: { status: "active" }
}, {
$group: {
_id: "$category",
count: { $sum: 1 },
avgPrice: { $avg: "$price" }
}
}, {
$sort: { count: -1 }
}]
// Paste into CompassAggregation tab
// ClickExport to Language -> Node.js
// No syntax errors — Compass validated it live
Output
Result set with category, count, avgPrice — sorted descending.
Production Trap:
NEVER run bulk delete or update via Compass on a production collection. Compass skips write concern settings and can trigger full collection locks.
Key Takeaway
Use Compass to visually inspect and validate queries before writing production CRUD code.
MongoDB Atlas Setup — From Zero to First Collection Without a Local Install
You want to write CRUD queries, not wrestle with daemons, config files, or missing dependencies. MongoDB Atlas lets you skip all that. It's a cloud-hosted cluster you can spin up in five minutes, free tier included. No local install, no brew, no apt-get. Why run a database on your laptop when you can hit a fully managed endpoint from your code? The payoff: you test against a production-like environment immediately, and your first collection is waiting after one API call. Here's the exact path: sign up at atlas.mongodb.com, click "Build a Database" (free M0 sandbox is fine), pick a cloud provider and region, create a database user with a password, whitelist your IP (or 0.0.0.0/0 for dev), get your connection string, connect via MongoDB Shell or Compass, then create your first database and collection with a single insert. Done.
ConnectAndInsert.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — database tutorial
// Step1: Connect from shell using your AtlasURI
mongosh "mongodb+srv://cluster0.xxxxx.mongodb.net/" --username <user>
// Step2: Create db and collection in one insert
testdb.users.insertOne({ name: "Jane", role: "dev" })
// Step3: Verify
show dbs
testdb.users.find()
// Output:
// { _id: ObjectId("..."), name: "Jane", role: "dev" }
Never expose your Atlas credentials in client-side code or public repos. Use environment variables and IP whitelisting. The free tier pauses after 60 days of inactivity; set a reminder or your cluster disappears.
Key Takeaway
Atlas removes the local install tax — spin up a cluster, get a connection string, and write your first document without touching a config file.
● Production incidentPOST-MORTEMseverity: high
Accidental Bulk Update Wipes Fields on 10k Orders
Symptom
Orders showed only the field that was updated; all other fields missing.
Assumption
The updateOne call with a plain document would only change the specified field.
Root cause
updateOne with a plain document as second argument replaces the whole document; $set is required for partial update.
Fix
Add $set operator to the update call; restore from backup for affected documents.
Key lesson
Always use $set for partial updates; a single-line omit can cost hours of data recovery.
Write a small script to validate the update with findOne before executing on production.
Production debug guideQuick reference for common MongoDB query failures in production4 entries
Symptom · 01
Query returns zero results even though document exists
→
Fix
Check that _id is compared with correct type (string vs ObjectId). Use typeof filter._id; if string, convert with new ObjectId().
Symptom · 02
find() returns cursor metadata instead of documents
→
Fix
Chain .toArray() or use for await...of. Logging find() without materialisation prints cursor info, not data.
Symptom · 03
Update doesn't change any documents
→
Fix
Check matchedCount vs modifiedCount in result object. If matchedCount is 0, filter misses. If modifiedCount is 0, document already matches intended state.
Symptom · 04
insertMany fails validation on first doc and skips rest
→
Fix
Set {ordered: false} to continue inserting valid documents. Check result.writeErrors for details of failed docs.
★ MongoDB CRUD Quick Debug Cheat SheetThree common production failures and the exact steps to diagnose and fix them
No results when querying by _id−
Immediate action
Check the type of the _id value in your filter
Commands
typeof filter._id
If string, convert: new ObjectId(filter._id)
Fix now
Use ObjectId constructor in the query
Update silently deletes fields+
Immediate action
Inspect the update argument for missing $set operator
Commands
console.log('Update argument:', update);
If it's a plain object, wrap in { $set: update }
Fix now
Restore from backup; add $set to all future updates
passing a plain object as the update argument replaces the whole document silently, which is almost never what you want.
2
find() returns a lazy Cursor, not an array
always chain .toArray() or use for await...of, and always add .limit() to open-ended queries before they hit production traffic.
3
findOneAndDelete() and findOneAndUpdate() aren't just convenience methods
they're the only way to atomically claim-and-act on a document without race conditions between concurrent processes.
4
Soft-deletes (adding isDeleted
true and deletedAt fields) preserve your audit trail and make GDPR compliance practical. Reserve physical deletion for temp data, test fixtures, and scheduled purge jobs.
5
Choose writeConcern based on data criticality
w:majority for payments, w:1 for logs. Write idempotent operations and implement retry logic for transient errors.
Common mistakes to avoid
4 patterns
×
Updating without $set
Symptom
Document silently replaced; all fields except the update values are lost.
Fix
Always wrap your changes in a $set (or $inc, $push) operator. Example: updateOne({ _id: id }, { $set: { status: 'active' } }).
×
Comparing string IDs to ObjectId
Symptom
Queries return zero results with no error because the types don't match.
Fix
Wrap the ID string in new ObjectId('...') before using in filter. Always ensure _id field is compared as ObjectId.
×
Using deprecated count() instead of countDocuments()
Symptom
Inaccurate document counts on sharded clusters, leading to pagination bugs.
Fix
Always use countDocuments(filter) which scans live data and respects filters. Avoid count() entirely.
×
Not using projection in reads
Symptom
Massive network bandwidth waste and slow serialization from fetching all fields.
Fix
Always specify projection in find() and findOne() options. Pro tip: exclude _id if not needed.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What's the difference between updateOne() and replaceOne() in MongoDB, a...
Q02SENIOR
How would you implement an atomic 'claim a task from a queue' operation ...
Q03SENIOR
If countDocuments() and estimatedDocumentCount() both count documents, w...
Q01 of 03SENIOR
What's the difference between updateOne() and replaceOne() in MongoDB, and when would you deliberately choose replaceOne()?
ANSWER
updateOne modifies specific fields using operators like $set, while replaceOne replaces the entire document with a new one. You'd choose replaceOne when you want to change the document's structure entirely — for example, after a GDPR data erasure request where you need to replace a user document with a minimal anonymised version, keeping only the _id.
Q02 of 03SENIOR
How would you implement an atomic 'claim a task from a queue' operation in MongoDB without creating a race condition between two simultaneous workers?
ANSWER
Use findOneAndDelete with a filter for queued tasks and a sort to pick the highest priority. This atomically removes the document and returns it to the worker. For example: db.jobs.findOneAndDelete({ status: 'queued' }, { sort: { priority: -1, queuedAt: 1 } }). This ensures only one worker gets each job.
Q03 of 03SENIOR
If countDocuments() and estimatedDocumentCount() both count documents, why do they exist separately — and which would you use on a 50-million-document collection for a real-time dashboard?
ANSWER
estimatedDocumentCount uses collection metadata and is very fast but can be slightly stale on sharded clusters. countDocuments actually scans documents (using an index) and respects filters, returning an exact count but much slower. For a real-time dashboard on a 50M collection, use estimatedDocumentCount (which is typically under a millisecond) because the small inaccuracy (usually less than a second old) is negligible compared to the performance cost of scanning 50M documents. Only use countDocuments when you need exact counts with filters.
01
What's the difference between updateOne() and replaceOne() in MongoDB, and when would you deliberately choose replaceOne()?
SENIOR
02
How would you implement an atomic 'claim a task from a queue' operation in MongoDB without creating a race condition between two simultaneous workers?
SENIOR
03
If countDocuments() and estimatedDocumentCount() both count documents, why do they exist separately — and which would you use on a 50-million-document collection for a real-time dashboard?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
What is the difference between insertOne and insertMany in MongoDB?
insertOne() adds a single document and returns a result with the new document's _id. insertMany() adds an array of documents in one network round-trip, which is dramatically faster for bulk loads. The key option to know is { ordered: false }, which tells MongoDB to continue inserting valid documents even if some fail, rather than aborting the entire batch on the first error.
Was this helpful?
02
Why does my MongoDB updateOne() query delete all my document fields?
You're passing a plain object as the second argument instead of using an update operator. updateOne({ _id: id }, { status: 'active' }) replaces the document entirely. You need updateOne({ _id: id }, { $set: { status: 'active' } }) — the $set operator tells MongoDB to modify only the named fields and leave everything else alone.
Was this helpful?
03
Does MongoDB have transactions like SQL databases?
Yes — since version 4.0, MongoDB supports multi-document ACID transactions. For single-document operations, MongoDB has always been atomic. Multi-document transactions are available on replica sets and sharded clusters, but they come with a performance cost. The best practice is to model your data so that most operations only need to touch one document, reserving transactions for the rare cases where you genuinely need cross-collection atomicity.
Was this helpful?
04
What is writeConcern and how does it affect durability?
writeConcern controls how many nodes acknowledge a write. w:1 acknowledges primary only; w:majority waits for replica set majority. Higher writeConcern increases durability but adds latency. Set to majority for critical data, 1 for logs or non-critical.