✏️ MongoDB CRUD Operations
Every application, no matter how fancy, boils down to four verbs: Create, Read, Update, Delete. Master these against MongoDB and you can build the data layer of almost anything. In this lesson you'll drive them with the official Node.js driver using clean async/await — the exact code that runs behind a real Express API.
Week 8 · Day 3 (Wednesday: MongoDB Basics) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Connect to MongoDB from Node.js and get a handle on a collection
- Insert documents with
insertOneandinsertMany - Query documents with
find/findOneand read the query operators$gt,$in, and$regex - Update documents with
updateOneand the$setoperator - Remove documents safely with
deleteOneanddeleteMany - Explain what an index is and create a basic one for faster reads
Estimated Time: 70 minutes
Practice: Write a small task-manager data layer covering all four CRUD verbs.
In This Lesson
CRUD in One Picture
CRUD is the contract between your application and the database. The app asks; the database answers. Each verb maps to one or two driver methods — learn the map and you've learned the whole surface.
Throughout this lesson we use the official MongoDB Node.js driver (the mongodb package) with async/await. This is the foundation Mongoose is built on — knowing the raw driver makes the higher-level tools far less mysterious.
Connecting from Node.js
Before any CRUD, you need a live connection and a reference to a collection. Install the driver, then keep your connection string in an environment variable — never hard-code credentials.
// Terminal: npm install mongodb dotenv
import { MongoClient } from 'mongodb';
import 'dotenv/config';
// The connection string lives in .env, e.g.
// MONGODB_URI=mongodb+srv://user:pass@cluster0.xxxx.mongodb.net
const client = new MongoClient(process.env.MONGODB_URI);
await client.connect(); // open the connection once, up front
const db = client.db('shop'); // pick a database
const products = db.collection('products'); // get a collection handle
console.log('Connected — ready for CRUD');
⚠️ Keep secrets out of your code
Put MONGODB_URI in a .env file and add .env to .gitignore. A connection string contains a username and password — committing it to Git is one of the most common ways databases get breached. The modern driver no longer needs the old useNewUrlParser/useUnifiedTopology flags; they're deprecated and can be dropped.
In the CRUD examples below, assume products is the collection handle from above. All driver methods return promises, so we await them.
Create: insertOne & insertMany
Create adds new documents. Use insertOne for a single document and insertMany for a batch — batching is dramatically faster than looping one insert at a time.
Insert a single document
const result = await products.insertOne({
name: 'Smartphone X1',
price: 699.99,
category: 'Electronics',
specs: { storage: '128GB', color: 'Black' },
tags: ['phone', 'mobile'],
inStock: true,
createdAt: new Date() // a real BSON Date, not a string
});
console.log(result.insertedId); // → the auto-generated ObjectId
Output
{ acknowledged: true, insertedId: new ObjectId("665f0a12ab34cd56ef789012") }
If you don't provide an _id, MongoDB creates an ObjectId for you and hands it back in result.insertedId. The insert is atomic — it fully succeeds or fully fails.
Insert many documents
const result = await products.insertMany([
{ name: 'Laptop Pro', price: 1299.99, category: 'Electronics', inStock: true },
{ name: 'Wireless Earbuds', price: 149.99, category: 'Audio', inStock: true },
{ name: 'Smart Watch', price: 299.99, category: 'Wearables', inStock: false }
]);
console.log(result.insertedCount); // → 3
console.log(result.insertedIds); // → { '0': ObjectId(...), '1': ..., '2': ... }
💡 ordered inserts
By default insertMany is ordered: it stops at the first failing document. Pass { ordered: false } to keep going and insert every valid document, collecting the errors at the end — useful for bulk imports where one bad row shouldn't sink the whole batch.
Read: find, findOne & operators
Read is where you'll spend most of your time. You describe the documents you want with a query filter — a plain object — and MongoDB returns the matches.
The basics
// find() returns a CURSOR, not an array — call toArray() to materialize it
const all = await products.find().toArray();
// A filter is just an object: field → value means "equals"
const electronics = await products.find({ category: 'Electronics' }).toArray();
// findOne() returns a single document (or null), no cursor needed
const one = await products.findOne({ name: 'Laptop Pro' });
⚠️ find gives you a cursor
find() does not return documents directly — it returns a cursor, a pointer that fetches results lazily. Call .toArray() for small result sets, or iterate with for await (const doc of cursor) for large ones so you don't load everything into memory at once.
Query operators
Equality only gets you so far. Query operators — keys that start with $ — express ranges, lists, and patterns. Here are the three you'll use constantly.
// $gt / $gte / $lt / $lte — comparisons
await products.find({ price: { $gt: 500 } }).toArray(); // price > 500
await products.find({ price: { $gte: 100, $lt: 300 } }).toArray(); // 100 ≤ price < 300
// $in — value is one of a list
await products.find({ category: { $in: ['Audio', 'Wearables'] } }).toArray();
// $regex — string matches a pattern ( i = case-insensitive )
await products.find({ name: { $regex: 'watch', $options: 'i' } }).toArray();
// Multiple fields in one filter are combined with AND
await products.find({ category: 'Electronics', inStock: true }).toArray();
// Dot notation reaches into embedded documents
await products.find({ 'specs.storage': '128GB' }).toArray();
| Operator | Meaning | Example |
|---|---|---|
$gt / $gte | Greater than / or equal | { price: { $gt: 500 } } |
$lt / $lte | Less than / or equal | { age: { $lte: 30 } } |
$ne | Not equal | { inStock: { $ne: false } } |
$in | Matches any value in a list | { category: { $in: ['A','B'] } } |
$regex | String matches a pattern | { name: { $regex: 'pro', $options: 'i' } } |
Shaping results: projection, sort, limit
const rows = await products
.find({ inStock: true })
.project({ name: 1, price: 1, _id: 0 }) // include name & price, drop _id
.sort({ price: -1 }) // -1 descending, 1 ascending
.limit(5) // top 5 only
.toArray();
Projection keeps payloads small by returning only the fields you need, sort orders the results, and limit caps how many come back — always limit in production so a huge collection can't flood your app.
Update: updateOne & $set
Update modifies existing documents. The critical rule: an update takes two arguments — a filter (which documents) and an update (what to change) — and the change is written with update operators like $set.
// Change specific fields, leaving the rest of the document untouched
const result = await products.updateOne(
{ name: 'Smartphone X1' }, // filter: which document
{ $set: { price: 649.99, 'specs.color': 'Blue' } } // update: what to change
);
console.log(result.matchedCount); // 1 — one document matched the filter
console.log(result.modifiedCount); // 1 — one document was actually changed
⚠️ Never forget $set
Writing updateOne({ name: 'X1' }, { price: 649.99 }) without $set is a classic beginner trap — older behavior would replace the entire document with just { price: 649.99 }, wiping every other field. Always wrap your changes in an update operator. (The modern driver actually throws an error if you pass a plain object, which is a helpful guardrail — but build the habit anyway.)
More update operators
// $inc — add to a number (use a negative value to subtract)
await products.updateOne({ name: 'Laptop Pro' }, { $inc: { price: -100 } });
// $push — append to an array
await products.updateOne({ name: 'Smart Watch' }, { $push: { tags: 'sale' } });
// $unset — remove a field entirely
await products.updateOne({ name: 'Smart Watch' }, { $unset: { color: '' } });
// updateMany — apply to EVERY matching document
await products.updateMany(
{ category: 'Electronics' },
{ $set: { onSale: true } }
);
// upsert — update if found, otherwise insert a new document
await products.updateOne(
{ name: 'Tablet Mini' },
{ $set: { price: 349.99, category: 'Electronics' } },
{ upsert: true }
);
✅ updateOne vs updateMany
updateOne changes the first matching document; updateMany changes all of them. Reach for updateOne by default — it's the safer choice when you mean to touch a single record.
Delete: deleteOne & deleteMany
Delete removes documents. The same one-vs-many split applies, and the filter is everything — a mistake here is unrecoverable.
// Remove a single document
const result = await products.deleteOne({ name: 'Wireless Earbuds' });
console.log(result.deletedCount); // → 1
// Remove every matching document
await products.deleteMany({ inStock: false });
// findOneAndDelete returns the document it removed — handy for logging/undo
const removed = await products.findOneAndDelete({ name: 'Smart Watch' });
console.log('Deleted:', removed?.name);
⚠️ deleteMany({}) empties the collection
An empty filter matches everything. deleteMany({}) deletes every document in the collection — there is no confirmation and no trash bin. Double-check your filter before every delete, and for important data prefer a soft delete (a { $set: { deleted: true } } flag) so records can be recovered.
Indexes: Faster Reads
Without an index, MongoDB answers a query by scanning every document in the collection — fine for 50 documents, catastrophic for 5 million. An index is a sorted data structure that lets the database jump straight to matches, exactly like the index at the back of a textbook lets you find a topic without reading every page.
// Create an ascending index on a single field
await products.createIndex({ category: 1 }); // 1 ascending, -1 descending
// Compound index — supports queries that filter/sort on both fields
await products.createIndex({ category: 1, price: -1 });
// Unique index — the database rejects duplicate values
await products.createIndex({ sku: 1 }, { unique: true });
// See what indexes exist
console.log(await products.indexes());
💡 Index the fields you query
A good rule to start: add an index for any field you frequently filter or sort on. Every collection already has an index on _id. Don't over-index, though — each index speeds up reads but adds a little cost to every write and uses storage. Index deliberately, based on your real queries.
Practice & Quiz
🏋️ Exercise 1: A task-manager data layer
Goal: Using a tasks collection handle, write four functions — one per CRUD verb. A task looks like { title, done, priority, createdAt }.
// TODO: implement each function with the driver
async function addTask(title, priority) { /* insert a new, not-done task */ }
async function getOpenTasks() { /* all tasks where done is false, newest first */ }
async function completeTask(id) { /* set done: true for the task with this _id */ }
async function removeTask(id) { /* delete the task with this _id */ }
💡 Hint
To match by _id you must wrap the string in new ObjectId(id) — a filter of { _id: id } with a plain string won't match an ObjectId. Import ObjectId from the mongodb package.
✅ Solution
import { ObjectId } from 'mongodb';
async function addTask(title, priority) {
const result = await tasks.insertOne({
title,
priority,
done: false,
createdAt: new Date()
});
return result.insertedId;
}
async function getOpenTasks() {
return tasks
.find({ done: false })
.sort({ createdAt: -1 }) // newest first
.toArray();
}
async function completeTask(id) {
const result = await tasks.updateOne(
{ _id: new ObjectId(id) },
{ $set: { done: true, completedAt: new Date() } }
);
return result.modifiedCount; // 1 if it worked, 0 if nothing matched
}
async function removeTask(id) {
const result = await tasks.deleteOne({ _id: new ObjectId(id) });
return result.deletedCount;
}
🏋️ Exercise 2: Build a query filter
Goal: Write a single find filter that returns products in the Electronics or Audio category, priced under 800, that are in stock. Return only name and price, sorted cheapest first.
✅ Solution
const rows = await products
.find({
category: { $in: ['Electronics', 'Audio'] },
price: { $lt: 800 },
inStock: true
})
.project({ name: 1, price: 1, _id: 0 })
.sort({ price: 1 })
.toArray();
🎯 Quick Quiz
Question 1: What does products.find({ category: 'Audio' }) return before you call .toArray()?
Question 2: Which update keeps a document's other fields intact while changing only its price?
Question 3: Why add an index to a field you query often?
Best Practices & Pitfalls
✅ Do
- Use
insertManyand bulk writes instead of looping single inserts - Always
.limit()and.project()production queries to keep payloads small - Wrap changes in update operators (
$set,$inc,$push) - Wrap
_idstrings innew ObjectId(...)before filtering - Index the fields you filter and sort on most
❌ Don't
- Call
deleteMany({})orupdateMany({}, ...)without triple-checking the empty filter - Load huge collections with
.toArray()— iterate the cursor instead - Assume an update matched — check
matchedCount/modifiedCount - Leave leading-wildcard regexes like
/^.*x/in hot queries — they can't use an index
✅ Always check the result object
Every write returns a result: insertedId, matchedCount, modifiedCount, deletedCount. Inspecting these tells you whether the operation actually did what you expected — an update with matchedCount: 0 silently changed nothing, which is a bug waiting to be noticed.
Summary
🎉 Key Takeaways
- Create with
insertOne/insertMany— MongoDB fills in the_id - Read with
find(returns a cursor →.toArray()) andfindOne, refined by operators like$gt,$in,$regex - Update with a filter + an update operator such as
$set— never a bare object - Delete with
deleteOne/deleteMany— guard the filter carefully - Shape reads with
project,sort, andlimit - Indexes turn full-collection scans into fast lookups on the fields you query
📚 Additional Resources
- MongoDB Docs — CRUD Operations
- MongoDB Docs — Query Operators
- MongoDB Docs — Node.js Driver
- MongoDB Docs — Indexes
🚀 What's Next?
You've been connecting with a raw URI string. Next you'll set up a real cloud database properly, from account to connection: MongoDB Atlas Setup — the free-tier cluster that will host every project from here on.
🎉 You can drive a database now!
Create, read, update, delete — the four verbs behind every app you'll ever build.