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

  1. CRUD Overview
  2. Create — Inserting Documents
  3. Read — Finding Documents
  4. Query Operators
  5. Projection — Selecting Fields
  6. Sorting
  7. Pagination — skip and limit
  8. Update — Modifying Documents
  9. Update Operators
  10. Delete — Removing Documents
  11. Chaining Queries
  12. lean() — Performance Optimization
  13. exec() and Promises
  14. Error Handling Patterns
  15. Key Takeaways
  16. Explain-It Challenge

1. CRUD Overview

OperationHTTP MethodMongoose Methods
CreatePOSTModel.create(), new Model().save()
ReadGETModel.find(), findOne(), findById()
UpdatePUT / PATCHfindByIdAndUpdate(), updateOne(), updateMany()
DeleteDELETEfindByIdAndDelete(), 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
MethodRuns validators?Runs middleware?Speed
Model.create()YesYes (pre('save'))Normal
new Model().save()YesYes (pre('save'))Normal
Model.insertMany()Yes (by default)NoFaster

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

OperatorMeaningExample
$gtGreater than{ age: { $gt: 25 } }
$gteGreater than or equal{ age: { $gte: 18 } }
$ltLess than{ age: { $lt: 30 } }
$lteLess than or equal{ age: { $lte: 65 } }
$eqEqual (implicit){ role: 'admin' }
$neNot equal{ role: { $ne: 'banned' } }
$inIn array{ role: { $in: ['a', 'b'] } }
$ninNot in array{ role: { $nin: ['x'] } }
$orLogical OR{ $or: [{...}, {...}] }
$andLogical AND{ $and: [{...}, {...}] }
$regexPattern match{ name: { $regex: /^A/i } }
$existsField exists{ field: { $exists: true } }
$allArray contains all{ tags: { $all: ['a', 'b'] } }
$elemMatchArray 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 ValueMeaning
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 _id or 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

MethodReturns document?Runs validators?Runs save middleware?
findByIdAndUpdate()Yes (with new: true)Only with runValidators: trueNo
updateOne()No (returns result object)Only with runValidators: trueNo
updateMany()No (returns result object)Only with runValidators: trueNo
doc.save()Yes (the document)AlwaysYes

Important: findByIdAndUpdate does NOT run pre('save') middleware. If you have password hashing in pre('save'), use findById() + 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 } });
OperatorDescription
$setSet field value
$unsetRemove a field
$incIncrement / decrement
$pushAppend to array
$pullRemove from array by value
$addToSetAdd to array if unique
$popRemove first (-1) or last (1) from array
$renameRename a field
$min / $maxConditional update (only if less/greater)
$mulMultiply 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()

ScenarioUse lean()?
Returning data via API (res.json)Yes -- no need for Mongoose features
Displaying data in a templateYes -- read-only rendering
Modifying and saving the documentNo -- need .save() and change tracking
Using virtuals or instance methodsNo -- lean strips them (unless configured)
Populating referencesYes -- 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 await with or without .exec(). The .exec() call is optional with await but 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

  1. Create: Use Model.create() for single/batch inserts; use new Model().save() when you need pre-save manipulation.
  2. Read: find() returns arrays, findOne() and findById() return single documents or null.
  3. Query operators: $gt, $lt, $in, $or, $regex, $exists -- all passed as nested objects.
  4. Projection: .select('name email') to include or .select('-password') to exclude.
  5. Sort: .sort({ createdAt: -1 }) for descending; .sort('name') for ascending.
  6. Paginate: .skip((page - 1) * limit).limit(limit) with countDocuments() for total.
  7. Update: findByIdAndUpdate() with { new: true, runValidators: true } for API responses.
  8. Update operators: $set, $inc, $push, $pull, $addToSet for targeted modifications.
  9. Delete: findByIdAndDelete() returns the document; consider soft deletes for production.
  10. lean(): Use for all read-only queries -- significantly faster, less memory.
  11. Error handling: Catch ValidationError (400), duplicate key 11000 (409), and CastError (400).

16. Explain-It Challenge

Build a complete CRUD API for a Product resource from memory: create, findAll (with filters, sort, pagination), findOne, update, and delete. Include proper error handling for validation errors, duplicate keys, and invalid IDs. Use lean() for read operations and runValidators: true for 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 >