🔧 CRUD Operations in MongoDB
Create, Read, Update, Delete — the four verbs behind every database-backed feature you'll ever build. In this lesson you'll drive all four against MongoDB from Node.js, using modern async/await, the query operators that make reads expressive, and the update operators that let you change documents surgically.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Connect to MongoDB from Node.js and manage the connection lifecycle safely
- Insert single and multiple documents with
insertOneandinsertMany - Query documents using comparison, logical, array, and element operators, plus projections and cursor methods
- Modify documents with
updateOne,updateMany, update operators, and upsert - Remove documents safely with
deleteOneanddeleteMany
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a small task-tracker script that runs the full CRUD cycle end to end.
In This Lesson
What Is CRUD?
CRUD stands for Create, Read, Update, Delete — the four fundamental things any application does with stored data. Signing up a user is a Create; loading a profile is a Read; editing a bio is an Update; closing an account is a Delete. Master these four and you can build the data layer of almost any app.
💡 The library analogy. Think of a collection as a library. Create is shelving a new book, Read is searching the catalog, Update is correcting a book's record, and Delete is removing a book from circulation. MongoDB simply gives you precise tools for each of those actions.
We'll use the official MongoDB Node.js driver (npm install mongodb). In a later lesson you'll layer Mongoose on top for schema validation, but working with the raw driver first shows you exactly what is happening underneath.
Connecting from Node.js
Every operation starts from a connected client. Create a small reusable connection module so the rest of your code never repeats connection boilerplate.
# Create and initialize the project
mkdir mongodb-crud-demo && cd mongodb-crud-demo
npm init -y
npm install mongodb dotenv
// connection.js
require('dotenv').config();
const { MongoClient } = require('mongodb');
// Read the URI from an environment variable — never hard-code credentials
const uri = process.env.MONGODB_URI || 'mongodb://localhost:27017';
const dbName = process.env.DB_NAME || 'crud_demo';
const client = new MongoClient(uri);
async function connectToDatabase() {
await client.connect();
console.log('Connected to MongoDB');
return client.db(dbName);
}
async function closeConnection() {
await client.close();
console.log('Connection closed');
}
module.exports = { connectToDatabase, closeConnection };
📖 Connection strings
Local: mongodb://localhost:27017
Atlas (cloud): mongodb+srv://user:pass@cluster0.mongodb.net/crud_demo?retryWrites=true&w=majority
The +srv form auto-discovers the cluster's replica-set members; w=majority waits for a majority of nodes to acknowledge each write.
⚠️ Keep secrets out of source control
Store your URI in a .env file and add .env to .gitignore. A connection string committed to a public repo hands an attacker your entire database.
Create: Inserting Documents
Use insertOne() for a single document and insertMany() for a batch. MongoDB adds an _id automatically if you don't provide one, and returns the generated id(s).
// insert.js
const { connectToDatabase, closeConnection } = require('./connection');
async function run() {
try {
const db = await connectToDatabase();
const products = db.collection('products');
// Insert one document
const one = await products.insertOne({
name: 'Laptop',
price: 999.99,
category: 'Electronics',
inStock: true,
tags: ['computers', 'work'],
createdAt: new Date()
});
console.log('Inserted _id:', one.insertedId);
// Insert many documents in a single round trip
const many = await products.insertMany([
{ name: 'Smartphone', price: 699.99, category: 'Electronics', inStock: true, tags: ['mobile'] },
{ name: 'Headphones', price: 149.99, category: 'Audio', inStock: false, tags: ['audio', 'wireless'] }
]);
console.log(`Inserted ${many.insertedCount} documents`);
} finally {
await closeConnection();
}
}
run().catch(console.error);
Console output
Connected to MongoDB
Inserted _id: new ObjectId("65b2f1a...")
Inserted 2 documents
Connection closed
💡 ordered: true vs. false
By default insertMany is ordered — it stops at the first error (e.g. a duplicate _id) and leaves the rest uninserted. Pass { ordered: false } to keep going and insert every valid document, collecting errors at the end. Choose ordered for "all-or-in-sequence" imports, unordered for "best-effort" bulk loads.
Read: Querying Documents
find() returns a cursor over many documents (call .toArray() to materialize them); findOne() returns a single document or null. A query is just a document describing what to match.
const products = db.collection('products');
// Everything
const all = await products.find().toArray();
// Exact match
const electronics = await products.find({ category: 'Electronics' }).toArray();
// Comparison operator: price less than 200
const affordable = await products.find({ price: { $lt: 200 } }).toArray();
// Multiple conditions are combined with AND
const inStockElectronics = await products.find({
category: 'Electronics',
inStock: true
}).toArray();
// OR condition
const techOrAudio = await products.find({
$or: [{ category: 'Electronics' }, { category: 'Audio' }]
}).toArray();
// Query a nested field with dot notation
const oled = await products.find({ 'specs.screen': '6.7 inch OLED' }).toArray();
// Array membership: matches if the tags array contains 'wireless'
const wireless = await products.find({ tags: 'wireless' }).toArray();
// A single document (or null)
const laptop = await products.findOne({ name: 'Laptop' });
Projections and cursor methods
Return only the fields you need with a projection, and shape result sets with sort, skip, and limit — the building blocks of pagination.
// Only name and price, exclude _id
const slim = await products
.find({}, { projection: { name: 1, price: 1, _id: 0 } })
.toArray();
// Page 2, 10 per page, newest first
const page = 2, pageSize = 10;
const results = await products
.find({ inStock: true })
.sort({ createdAt: -1 }) // -1 = descending, 1 = ascending
.skip((page - 1) * pageSize)
.limit(pageSize)
.toArray();
// Counting and distinct values
const total = await products.countDocuments({ category: 'Electronics' });
const categories = await products.distinct('category');
⚠️ skip() gets slow at scale
skip() still walks past every skipped document, so deep pagination (page 5,000) is expensive. For large datasets, prefer range-based pagination — e.g. find({ _id: { $gt: lastSeenId } }).limit(pageSize) — which jumps straight to the next slice using an index.
Query Operators Reference
Operators start with $ and turn a plain match into an expressive query. These are the ones you'll use daily.
| Need | Query | Meaning |
|---|---|---|
| Exact match | { status: "active" } | status equals "active" |
| Range | { age: { $gte: 25, $lt: 50 } } | 25 ≤ age < 50 |
| In a set | { category: { $in: ["A", "B"] } } | category is A or B |
| OR | { $or: [{ a: 1 }, { b: 2 }] } | a is 1 OR b is 2 |
| Field exists | { phone: { $exists: true } } | has a phone field |
| Array contains all | { tags: { $all: ["x", "y"] } } | tags has both x and y |
| Nested field | { "address.city": "NYC" } | address.city equals NYC |
| Match array element | { items: { $elemMatch: { qty: { $gt: 5 } } } } | an item with qty > 5 |
| Regex | { name: { $regex: "^J", $options: "i" } } | name starts with J (case-insensitive) |
Update: Modifying Documents
An update has two parts: a filter (which documents) and an update document built from operators like $set. Use updateOne for the first match, updateMany for all matches, and replaceOne to swap an entire document (keeping its _id).
const products = db.collection('products');
// Update the first matching document
const res = await products.updateOne(
{ name: 'Laptop' }, // filter
{ $set: { price: 1099.99, 'specs.ram': '32GB', updatedAt: new Date() } }
);
console.log(`Matched ${res.matchedCount}, modified ${res.modifiedCount}`);
// Update every matching document — 10% off all electronics
await products.updateMany(
{ category: 'Electronics' },
{ $set: { onSale: true }, $mul: { price: 0.9 } }
);
The update operators you'll reach for
| Operator | Does | Example |
|---|---|---|
$set | Set a field's value | { $set: { status: "active" } } |
$unset | Remove a field | { $unset: { temp: "" } } |
$inc | Increment (or decrement) a number | { $inc: { views: 1, stock: -1 } } |
$mul | Multiply a number | { $mul: { price: 0.9 } } |
$rename | Rename a field | { $rename: { name: "title" } } |
$push | Append to an array | { $push: { tags: "new" } } |
$pull | Remove matching array elements | { $pull: { tags: "old" } } |
$addToSet | Append only if not already present | { $addToSet: { tags: "unique" } } |
$setOnInsert | Set only when an upsert creates the doc | { $setOnInsert: { createdAt: new Date() } } |
Upsert: update if it exists, otherwise insert
Passing { upsert: true } tells MongoDB to create the document when the filter matches nothing. It's perfect for "save settings" or counters that may not exist yet.
// If "Tablet Pro" doesn't exist, it's created; otherwise its price is updated.
const result = await products.updateOne(
{ name: 'Tablet Pro' },
{
$set: { price: 549.99, category: 'Electronics', inStock: true },
$setOnInsert: { createdAt: new Date() } // only applied on creation
},
{ upsert: true }
);
console.log('Upserted id:', result.upsertedId); // set only when a new doc was created
⚠️ Don't forget the operator
With the modern driver, updateOne({ ... }, { price: 10 }) throws — an update needs an operator like $set. If you truly want to swap the whole document, use replaceOne instead. And never run an updateMany with an empty {} filter unless you really mean "every document".
Delete: Removing Documents
deleteOne() removes the first match; deleteMany() removes all matches. Both return a deletedCount.
const products = db.collection('products');
// Remove a single document
const one = await products.deleteOne({ name: 'Headphones' });
console.log(`Deleted ${one.deletedCount}`);
// Remove every out-of-stock product
const many = await products.deleteMany({ inStock: false });
console.log(`Deleted ${many.deletedCount}`);
// Drop an entire collection (structure and all)
await products.drop();
🛑 Deletes are permanent
deleteMany({}) wipes the whole collection with no undo. Always double-check the filter, test destructive queries with a matching find() first, and in production prefer a soft delete — set { deletedAt: new Date() } and filter it out — so data can be recovered and audited.
Hands-on Exercise
🏋️ Build a Task Tracker
Objective: Run one full CRUD cycle against a tasks collection.
Instructions
- Create: insert three tasks, each with
title,done: false,priority(1–3), andcreatedAt. - Read: find all tasks with
priority >= 2, sorted by priority descending. - Update: mark one task as done using
$set, and bump every task's priority by 1 with$inc. - Delete: remove all completed tasks with
deleteMany. - Log the collection contents after each step.
💡 Hint
Reuse the connection.js module from earlier. Wrap the whole flow in one async function and call closeConnection() in a finally block so the process always exits cleanly.
✅ Sample solution
const { connectToDatabase, closeConnection } = require('./connection');
async function run() {
try {
const db = await connectToDatabase();
const tasks = db.collection('tasks');
await tasks.deleteMany({}); // start clean
// CREATE
await tasks.insertMany([
{ title: 'Write lesson', done: false, priority: 3, createdAt: new Date() },
{ title: 'Review PR', done: false, priority: 2, createdAt: new Date() },
{ title: 'Water plants', done: false, priority: 1, createdAt: new Date() }
]);
// READ
const important = await tasks
.find({ priority: { $gte: 2 } })
.sort({ priority: -1 })
.toArray();
console.log('Important:', important.map(t => t.title));
// UPDATE
await tasks.updateOne({ title: 'Review PR' }, { $set: { done: true } });
await tasks.updateMany({}, { $inc: { priority: 1 } });
// DELETE
const del = await tasks.deleteMany({ done: true });
console.log(`Removed ${del.deletedCount} completed task(s)`);
console.log('Remaining:', await tasks.find().toArray());
} finally {
await closeConnection();
}
}
run().catch(console.error);
Best Practices
✅ Do
- Use
async/awaitwithtry/catch/finallyand close connections reliably. - Project only the fields you need to cut network and memory cost.
- Check
matchedCount/modifiedCount/deletedCountto confirm an operation did what you expected. - Index the fields you filter and sort on (covered later in the module).
- Prefer range-based pagination over deep
skip().
⚠️ Avoid
- Running
updateMany/deleteManywith an empty filter by accident. - Forgetting update operators (
$set,$inc) in an update document. - Querying
_idwith a raw string instead ofnew ObjectId(...). - Opening a fresh client per request — reuse one connected client across the app.
Summary & Quiz
🎉 Key Takeaways
- Create with
insertOne/insertMany;ordered:falsekeeps going past errors. - Read with
find(cursor) andfindOne; shape results with query operators, projections,sort,skip,limit. - Update with
updateOne/updateManyusing operators like$set,$inc,$push;upsertcreates-or-updates. - Delete with
deleteOne/deleteMany— permanent, so prefer soft deletes in production. - Always confirm the result counts and keep the connection lifecycle clean.
🎯 Quick Quiz
Question 1: Which method retrieves a single document (or null) matching a filter?
Question 2: You want to add 1 to a document's views field. Which update operator do you use?
Question 3: What does passing { upsert: true } to updateOne do when nothing matches the filter?
📚 Further Reading
🚀 What's Next?
You've driven the raw driver. Next we add structure and safety: Mongoose ODM Fundamentals brings schemas, validation, middleware, and population to your MongoDB code.
🎉 Nice work!
You can now create, read, update, and delete data with confidence. On to modeling it properly with Mongoose.