Episode 3 — NodeJS MongoDB Backend Architecture / 3.8 — Database Basics MongoDB
3.8.f — CRUD Operations with Mongoose
In one sentence: Mongoose provides a rich, chainable query API for creating, reading, updating, and deleting documents -- including powerful query operators, projection, sorting, pagination, and the
lean()optimization for read-heavy workloads.
< 3.8.e -- Mongoose ODM | 3.8.g -- Database Relations and Populate >
Table of Contents
- CRUD Overview
- Create — Inserting Documents
- Read — Finding Documents
- Query Operators
- Projection — Selecting Fields
- Sorting
- Pagination — skip and limit
- Update — Modifying Documents
- Update Operators
- Delete — Removing Documents
- Chaining Queries
- lean() — Performance Optimization
- exec() and Promises
- Error Handling Patterns
- Key Takeaways
- Explain-It Challenge
1. CRUD Overview
| Operation | HTTP Method | Mongoose Methods |
|---|---|---|
| Create | POST | Model.create(), new Model().save() |
| Read | GET | Model.find(), findOne(), findById() |
| Update | PUT / PATCH | findByIdAndUpdate(), updateOne(), updateMany() |
| Delete | DELETE | findByIdAndDelete(), deleteOne(), deleteMany() |
All Mongoose query methods return a thenable (Query object) that you can await or chain.
2. Create -- Inserting Documents
Method 1: Model.create() (recommended for most cases)
const User = require('../models/User');
// Create a single document
const user = await User.create({
name: 'Alice Johnson',
email: 'alice@example.com',
age: 28,
role: 'user'
});
console.log(user._id); // ObjectId
console.log(user.createdAt); // Date (if timestamps enabled)
Method 2: new Model() + .save() (when you need to manipulate before saving)
const user = new User({
name: 'Bob Smith',
email: 'bob@example.com'
});
// Manipulate before saving
user.role = 'admin';
user.hobbies.push('coding');
await user.save(); // Triggers validation + pre('save') middleware
Create multiple documents
const users = await User.create([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
{ name: 'Charlie', email: 'charlie@example.com' }
]);
console.log(users.length); // 3
insertMany() for bulk inserts (faster, less overhead)
const result = await User.insertMany([
{ name: 'Dave', email: 'dave@example.com' },
{ name: 'Eve', email: 'eve@example.com' }
]);
// Skips some middleware but is faster for large batches
| Method | Runs validators? | Runs middleware? | Speed |
|---|---|---|---|
Model.create() | Yes | Yes (pre('save')) | Normal |
new Model().save() | Yes | Yes (pre('save')) | Normal |
Model.insertMany() | Yes (by default) | No | Faster |
3. Read -- Finding Documents
Find all documents
const users = await User.find(); // all users
const admins = await User.find({ role: 'admin' }); // filtered
Find one document
const user = await User.findOne({ email: 'alice@example.com' });
// Returns null if not found (not an error)
Find by ID
const user = await User.findById('64a1b2c3d4e5f6a7b8c9d0e1');
// Shorthand for findOne({ _id: '...' })
// Returns null if not found
Count documents
const total = await User.countDocuments({ role: 'user' });
const exists = await User.exists({ email: 'alice@example.com' });
// exists returns { _id: ObjectId } or null
Distinct values
const roles = await User.distinct('role');
// ['user', 'admin', 'moderator']
4. Query Operators
Mongoose supports all MongoDB query operators by passing them as nested objects:
Comparison operators
// Greater than
await User.find({ age: { $gt: 25 } });
// Greater than or equal
await User.find({ age: { $gte: 18 } });
// Less than
await User.find({ age: { $lt: 30 } });
// Less than or equal
await User.find({ age: { $lte: 65 } });
// Not equal
await User.find({ role: { $ne: 'admin' } });
// In array of values
await User.find({ role: { $in: ['admin', 'moderator'] } });
// Not in array
await User.find({ role: { $nin: ['banned', 'suspended'] } });
// Range (combine operators)
await User.find({ age: { $gte: 18, $lte: 65 } });
Logical operators
// AND (implicit -- multiple conditions on same level)
await User.find({ role: 'admin', isActive: true });
// AND (explicit)
await User.find({ $and: [{ age: { $gte: 18 } }, { age: { $lte: 30 } }] });
// OR
await User.find({ $or: [{ role: 'admin' }, { role: 'moderator' }] });
// NOR (neither condition is true)
await User.find({ $nor: [{ role: 'banned' }, { isActive: false }] });
// NOT
await User.find({ age: { $not: { $gt: 30 } } });
Element operators
// Field exists
await User.find({ middleName: { $exists: true } });
// Field does not exist
await User.find({ deletedAt: { $exists: false } });
// Field is a specific BSON type
await User.find({ age: { $type: 'number' } });
String matching (regex)
// Contains 'alice' (case-insensitive)
await User.find({ name: { $regex: /alice/i } });
// Starts with 'A'
await User.find({ name: { $regex: /^A/ } });
// Using string pattern
await User.find({ name: { $regex: 'john', $options: 'i' } });
Array operators
// Array contains a value
await User.find({ hobbies: 'coding' });
// Array contains ALL specified values
await User.find({ hobbies: { $all: ['coding', 'reading'] } });
// Array has exact size
await User.find({ hobbies: { $size: 3 } });
// Array element matches multiple conditions
await User.find({
education: { $elemMatch: { school: 'MIT', year: { $gt: 2020 } } }
});
Quick reference table
| Operator | Meaning | Example |
|---|---|---|
$gt | Greater than | { age: { $gt: 25 } } |
$gte | Greater than or equal | { age: { $gte: 18 } } |
$lt | Less than | { age: { $lt: 30 } } |
$lte | Less than or equal | { age: { $lte: 65 } } |
$eq | Equal (implicit) | { role: 'admin' } |
$ne | Not equal | { role: { $ne: 'banned' } } |
$in | In array | { role: { $in: ['a', 'b'] } } |
$nin | Not in array | { role: { $nin: ['x'] } } |
$or | Logical OR | { $or: [{...}, {...}] } |
$and | Logical AND | { $and: [{...}, {...}] } |
$regex | Pattern match | { name: { $regex: /^A/i } } |
$exists | Field exists | { field: { $exists: true } } |
$all | Array contains all | { tags: { $all: ['a', 'b'] } } |
$elemMatch | Array element match | { arr: { $elemMatch: {...} } } |
5. Projection -- Selecting Fields
Control which fields are returned to reduce data transfer and improve performance.
Include specific fields
// Only return name and email (plus _id by default)
await User.find({}).select('name email');
// Object syntax
await User.find({}).select({ name: 1, email: 1 });
Exclude specific fields
// Return everything except password and __v
await User.find({}).select('-password -__v');
// Object syntax
await User.find({}).select({ password: 0, __v: 0 });
Rule: You cannot mix include and exclude in the same projection (except for
_id).
// This is OK -- exclude _id while including name
await User.find({}).select('name -_id');
// This is INVALID -- cannot mix include and exclude
// await User.find({}).select('name -password'); // ERROR
6. Sorting
// Sort by name ascending (A-Z)
await User.find({}).sort({ name: 1 });
// Sort by createdAt descending (newest first)
await User.find({}).sort({ createdAt: -1 });
// Sort by multiple fields
await User.find({}).sort({ role: 1, name: 1 });
// String syntax
await User.find({}).sort('name'); // ascending
await User.find({}).sort('-createdAt'); // descending
await User.find({}).sort('role -createdAt'); // role asc, date desc
| Sort Value | Meaning |
|---|---|
1 or 'asc' | Ascending (A-Z, 0-9, oldest first) |
-1 or 'desc' | Descending (Z-A, 9-0, newest first) |
7. Pagination -- skip and limit
Basic pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
const users = await User.find({ role: 'user' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit);
const total = await User.countDocuments({ role: 'user' });
res.json({
data: users,
pagination: {
currentPage: page,
totalPages: Math.ceil(total / limit),
totalItems: total,
itemsPerPage: limit,
hasNextPage: page < Math.ceil(total / limit),
hasPrevPage: page > 1
}
});
Reusable pagination helper
async function paginate(model, filter = {}, options = {}) {
const page = options.page || 1;
const limit = options.limit || 10;
const sort = options.sort || { createdAt: -1 };
const select = options.select || '';
const populate = options.populate || '';
const [data, total] = await Promise.all([
model.find(filter)
.sort(sort)
.skip((page - 1) * limit)
.limit(limit)
.select(select)
.populate(populate)
.lean(),
model.countDocuments(filter)
]);
return {
data,
pagination: {
currentPage: page,
totalPages: Math.ceil(total / limit),
totalItems: total,
itemsPerPage: limit
}
};
}
// Usage:
const result = await paginate(User, { role: 'user' }, {
page: 2, limit: 20, sort: { name: 1 }, select: 'name email'
});
Performance warning:
skip()becomes slow on large collections because MongoDB must scan and discard all skipped documents. For very large datasets, use cursor-based pagination (where the last document's_idor timestamp becomes the starting point for the next query).
8. Update -- Modifying Documents
findByIdAndUpdate() (most common in REST APIs)
const updatedUser = await User.findByIdAndUpdate(
req.params.id, // ID to find
{ name: 'Alice Updated', age: 29 }, // changes
{
new: true, // return the UPDATED document (default: old)
runValidators: true // run schema validators on update (default: false!)
}
);
if (!updatedUser) {
return res.status(404).json({ error: 'User not found' });
}
findOneAndUpdate()
const user = await User.findOneAndUpdate(
{ email: 'alice@example.com' }, // filter
{ $set: { role: 'admin' } }, // update
{ new: true, runValidators: true }
);
updateOne() and updateMany() (no document returned)
// Update one document
const result = await User.updateOne(
{ email: 'alice@example.com' },
{ $set: { isActive: true } }
);
console.log(result.modifiedCount); // 1
// Update many documents
const result = await User.updateMany(
{ role: 'user', lastLoginAt: { $lt: new Date('2025-01-01') } },
{ $set: { isActive: false } }
);
console.log(result.modifiedCount); // number of documents updated
Document-level update with .save()
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
user.name = req.body.name;
user.age = req.body.age;
await user.save(); // Triggers validators AND pre('save') middleware
res.json(user);
When to use which
| Method | Returns document? | Runs validators? | Runs save middleware? |
|---|---|---|---|
findByIdAndUpdate() | Yes (with new: true) | Only with runValidators: true | No |
updateOne() | No (returns result object) | Only with runValidators: true | No |
updateMany() | No (returns result object) | Only with runValidators: true | No |
doc.save() | Yes (the document) | Always | Yes |
Important:
findByIdAndUpdatedoes NOT runpre('save')middleware. If you have password hashing inpre('save'), usefindById()+ modify +save()instead.
9. Update Operators
// $set — set specific fields
await User.updateOne({ _id: id }, { $set: { name: 'New Name' } });
// $unset — remove a field from the document
await User.updateOne({ _id: id }, { $unset: { middleName: '' } });
// $inc — increment a numeric field
await User.updateOne({ _id: id }, { $inc: { loginCount: 1 } });
await Product.updateOne({ _id: id }, { $inc: { stock: -1 } }); // decrement
// $push — add to an array
await User.updateOne({ _id: id }, { $push: { hobbies: 'gaming' } });
// $push with $each — add multiple items
await User.updateOne({ _id: id }, {
$push: { hobbies: { $each: ['yoga', 'painting'] } }
});
// $pull — remove from an array
await User.updateOne({ _id: id }, { $pull: { hobbies: 'gaming' } });
// $addToSet — add only if not already present
await User.updateOne({ _id: id }, { $addToSet: { tags: 'premium' } });
// $rename — rename a field
await User.updateMany({}, { $rename: { 'oldField': 'newField' } });
// $min / $max — update only if new value is less/greater
await Product.updateOne({ _id: id }, { $min: { price: 9.99 } });
| Operator | Description |
|---|---|
$set | Set field value |
$unset | Remove a field |
$inc | Increment / decrement |
$push | Append to array |
$pull | Remove from array by value |
$addToSet | Add to array if unique |
$pop | Remove first (-1) or last (1) from array |
$rename | Rename a field |
$min / $max | Conditional update (only if less/greater) |
$mul | Multiply field value |
10. Delete -- Removing Documents
findByIdAndDelete() (returns the deleted document)
const deletedUser = await User.findByIdAndDelete(req.params.id);
if (!deletedUser) {
return res.status(404).json({ error: 'User not found' });
}
res.status(204).send(); // No content
findOneAndDelete()
const deleted = await User.findOneAndDelete({ email: 'alice@example.com' });
deleteOne() and deleteMany() (no document returned)
const result = await User.deleteOne({ _id: req.params.id });
console.log(result.deletedCount); // 1 or 0
// Delete many
const result = await User.deleteMany({ isActive: false, role: 'user' });
console.log(result.deletedCount); // number deleted
Soft delete pattern (recommended for production)
Instead of actually deleting, mark the document as deleted:
const userSchema = new Schema({
name: String,
email: String,
isDeleted: { type: Boolean, default: false },
deletedAt: Date
});
// "Delete" (soft)
await User.findByIdAndUpdate(id, {
isDeleted: true,
deletedAt: new Date()
});
// Auto-exclude deleted documents from queries
userSchema.pre('find', function() {
this.where({ isDeleted: { $ne: true } });
});
userSchema.pre('findOne', function() {
this.where({ isDeleted: { $ne: true } });
});
userSchema.pre('countDocuments', function() {
this.where({ isDeleted: { $ne: true } });
});
11. Chaining Queries
Mongoose queries are chainable -- build complex queries step by step:
const users = await User.find({ role: 'user' }) // filter
.select('name email age') // projection
.sort({ createdAt: -1 }) // sort
.skip(20) // pagination offset
.limit(10) // page size
.populate('createdBy', 'name') // join
.lean(); // performance
Building queries dynamically
app.get('/api/users', async (req, res) => {
let query = User.find();
// Dynamic filter
if (req.query.role) query = query.where('role').equals(req.query.role);
if (req.query.minAge) query = query.where('age').gte(parseInt(req.query.minAge));
if (req.query.search) query = query.where('name').regex(new RegExp(req.query.search, 'i'));
// Dynamic sort
const sortField = req.query.sort || '-createdAt';
query = query.sort(sortField);
// Pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
query = query.skip((page - 1) * limit).limit(limit);
// Projection
query = query.select('-password -__v');
const users = await query.lean();
res.json({ data: users });
});
12. lean() -- Performance Optimization
By default, Mongoose wraps every returned document in a Mongoose Document object with change tracking, virtuals, methods, and save/remove functionality. This adds overhead.
.lean() returns plain JavaScript objects instead -- significantly faster and using less memory.
// Without lean: returns Mongoose Documents (full features)
const users = await User.find().lean(false); // default
// With lean: returns plain JS objects (faster, less memory)
const users = await User.find().lean();
When to use lean()
| Scenario | Use lean()? |
|---|---|
Returning data via API (res.json) | Yes -- no need for Mongoose features |
| Displaying data in a template | Yes -- read-only rendering |
| Modifying and saving the document | No -- need .save() and change tracking |
| Using virtuals or instance methods | No -- lean strips them (unless configured) |
| Populating references | Yes -- lean works with .populate() |
Performance impact
// Benchmark example (1000 documents)
// Without lean: ~15ms
// With lean: ~5ms (3x faster, much less memory)
Rule of thumb: Use
.lean()for all read-only queries (API responses, templates). Omit it only when you need to modify and save the document or use instance methods.
13. exec() and Promises
Mongoose queries are thenables but not true Promises. Use .exec() for a true Promise with better stack traces:
// These are equivalent:
const users = await User.find({ role: 'admin' });
const users = await User.find({ role: 'admin' }).exec();
// With .then():
User.find({ role: 'admin' }).then(users => { ... });
User.find({ role: 'admin' }).exec().then(users => { ... });
Best practice: Use
awaitwith or without.exec(). The.exec()call is optional withawaitbut recommended for better debugging (stack traces include the query location).
14. Error Handling Patterns
Validation errors
try {
await User.create({ name: '', email: 'invalid' });
} catch (error) {
if (error.name === 'ValidationError') {
const messages = Object.values(error.errors).map(e => e.message);
return res.status(400).json({ errors: messages });
}
}
Duplicate key errors
try {
await User.create({ email: 'alice@example.com' });
} catch (error) {
if (error.code === 11000) {
const field = Object.keys(error.keyValue)[0];
return res.status(409).json({
error: `${field} already exists: ${error.keyValue[field]}`
});
}
}
Cast errors (invalid ObjectId)
try {
await User.findById('not-a-valid-id');
} catch (error) {
if (error.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
}
Complete error handler middleware
app.use((err, req, res, next) => {
// Mongoose validation error
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(e => e.message);
return res.status(400).json({ error: 'Validation failed', details: errors });
}
// Duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({ error: `Duplicate ${field}` });
}
// Invalid ObjectId
if (err.name === 'CastError') {
return res.status(400).json({ error: `Invalid ${err.path}: ${err.value}` });
}
// Generic server error
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
15. Key Takeaways
- Create: Use
Model.create()for single/batch inserts; usenew Model().save()when you need pre-save manipulation. - Read:
find()returns arrays,findOne()andfindById()return single documents ornull. - Query operators:
$gt,$lt,$in,$or,$regex,$exists-- all passed as nested objects. - Projection:
.select('name email')to include or.select('-password')to exclude. - Sort:
.sort({ createdAt: -1 })for descending;.sort('name')for ascending. - Paginate:
.skip((page - 1) * limit).limit(limit)withcountDocuments()for total. - Update:
findByIdAndUpdate()with{ new: true, runValidators: true }for API responses. - Update operators:
$set,$inc,$push,$pull,$addToSetfor targeted modifications. - Delete:
findByIdAndDelete()returns the document; consider soft deletes for production. - lean(): Use for all read-only queries -- significantly faster, less memory.
- Error handling: Catch
ValidationError(400), duplicate key11000(409), andCastError(400).
16. Explain-It Challenge
Build a complete CRUD API for a
Productresource from memory:create,findAll(with filters, sort, pagination),findOne,update, anddelete. Include proper error handling for validation errors, duplicate keys, and invalid IDs. Uselean()for read operations andrunValidators: truefor updates. If you can write the five route handlers without looking at notes, you have mastered this topic.
< 3.8.e -- Mongoose ODM | 3.8.g -- Database Relations and Populate >