Skip to content
intermediate Phase 10 · Database Integration

MongoDB & NoSQL

Work with document databases — schemas, CRUD operations, aggregation pipelines, and indexing.

1h 15m
0 problems
Topic Progress 0%

Document Modeling

Document Modeling

Embedded vs Referenced

MongoDB stores data as BSON documents in collections. Unlike relational tables, related data can be embedded directly within a document. Choosing between embedding and referencing is the core modeling decision in MongoDB.

// EMBEDDED: data accessed together, bounded growth
const userSchema = {
  _id: ObjectId,
  name: 'Alice',
  email: 'alice@example.com',
  address: {                    // embed 1-to-few
    street: '123 Main St',
    city: 'Portland',
    state: 'OR',
    zip: '97201'
  },
  preferences: {                // embed 1-to-1
    theme: 'dark',
    notifications: true
  },
  // Embedded array for bounded data
  recentSearches: [
    { query: 'react hooks', date: ISODate('2026-09-01') },
    { query: 'typescript generics', date: ISODate('2026-09-10') }
  ]
};

// REFERENCED: unbounded or independently accessed data
const postSchema = {
  _id: ObjectId,
  authorId: ObjectId('ref to users'),  // reference
  title: 'My Post',
  content: '...',
  tagIds: [ObjectId('ref to tags')],   // array of references
};

// Rule of thumb:
// - 1-to-few (< 100): embed
// - 1-to-many (unbounded): reference
// - Many-to-many: reference with junction collection

Mongoose Schemas

import mongoose from 'mongoose';

const postSchema = new mongoose.Schema({
  authorId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  title: { type: String, required: true, maxlength: 200 },
  slug: { type: String, unique: true, lowercase: true },
  content: { type: String, required: true },
  tags: [{ type: String, lowercase: true }],
  published: { type: Boolean, default: false },
  publishedAt: Date,
  viewCount: { type: Number, default: 0 },
}, {
  timestamps: true,  // adds createdAt, updatedAt
  toJSON: { virtuals: true },
  toObject: { virtuals: true },
});

// Virtual: author details (not stored, computed on access)
postSchema.virtual('author', {
  ref: 'User',
  localField: 'authorId',
  foreignField: '_id',
  justOne: true,
});

// Pre-save: generate slug
postSchema.pre('save', function(next) {
  if (this.isModified('title')) {
    this.slug = this.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
  }
  next();
});

// Static method: find by slug
postSchema.statics.findBySlug = function(slug) {
  return this.findOne({ slug });
};

// Instance method: increment views
postSchema.methods.incrementViews = function() {
  return this.model('Post').updateOne({ _id: this._id }, { $inc: { viewCount: 1 } });
};

const Post = mongoose.model('Post', postSchema);

Schema Validation

const productSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Product name is required'],
    trim: true,
    minlength: [3, 'Name must be at least 3 characters'],
    maxlength: [100, 'Name cannot exceed 100 characters']
  },
  price: {
    type: Number,
    required: true,
    min: [0, 'Price cannot be negative']
  },
  category: {
    type: String,
    enum: ['electronics', 'clothing', 'food', 'books'],
    required: true
  },
  sku: {
    type: String,
    unique: true,
    match: /^[A-Z]{3}-\d{4}$/  // regex validation
  }
}, {
  toJSON: { virtuals: true },
  toObject: { virtuals: true }
});

// Virtual for formatted price
productSchema.virtual('formattedPrice').get(function() {
  return `$${this.price.toFixed(2)}`;
});

// Compound unique index
productSchema.index({ category: 1, name: 1 }, { unique: true });

CRUD Operations

CRUD Operations

Basic Operations

MongoDB provides a rich set of methods for creating, reading, updating, and deleting documents. Mongoose wraps these with additional convenience methods and middleware.

// CREATE
const user = await User.create({
  name: 'Alice',
  email: 'alice@example.com',
  address: { street: '123 Main', city: 'Portland' }
});

// Bulk create
const users = await User.insertMany([
  { name: 'Bob', email: 'bob@example.com' },
  { name: 'Carol', email: 'carol@example.com' },
]);

// READ
const user = await User.findById(userId);
const posts = await Post.find({ authorId: userId, published: true })
  .sort({ createdAt: -1 })
  .limit(20)
  .select('title slug createdAt')  // only these fields
  .populate('author', 'name email');  // populate referenced user

// UPDATE
const updated = await Post.findByIdAndUpdate(
  postId,
  { $set: { title: 'New Title' }, $inc: { viewCount: 1 } },
  { new: true, runValidators: true }
);

// DELETE
await Post.findByIdAndDelete(postId);

// Soft delete pattern
await Post.findByIdAndUpdate(postId, { deletedAt: new Date() });
await Post.find({ deletedAt: { $exists: false } });  // exclude soft-deleted

Query Operators

// Comparison
{ age: { $gte: 18, $lte: 65 } }     // range
{ role: { $in: ['admin', 'editor'] } } // in list
{ email: { $regex: /^admin/ } }       // regex

// Logical
{ $or: [{ published: true }, { authorId: userId }] }
{ $and: [{ status: 'active' }, { credits: { $gt: 0 } }] }

// Array
{ tags: { $all: ['react', 'typescript'] } }  // has both
{ tags: 'react' }                              // has any
{ 'recentSearches': { $size: 3 } }             // array size

// Nested
{ 'address.state': 'OR' }
{ 'address.zip': { $gte: '97000', $lte: '97999' } }

// Exists
{ publishedAt: { $exists: true } }
{ deletedAt: { $exists: false } }

Update Operators

// Set a field
{ $set: { status: 'active', updatedAt: new Date() } }

// Increment/decrement
{ $inc: { viewCount: 1, credits: -10 } }

// Push to array
{ $push: { tags: 'new-tag', recentSearches: { $each: [search1, search2], $slice: -50 } } }

// Pull from array
{ $pull: { tags: 'old-tag' } }

// Add to set (no duplicates)
{ $addToSet: { followers: userId } }

// Remove field
{ $unset: { resetToken: '' } }

// Upsert: insert if not found
await User.findOneAndUpdate(
  { email: 'alice@example.com' },
  { $setOnInsert: { name: 'Alice', createdAt: new Date() } },
  { upsert: true, new: true }
);

Aggregation Pipeline

Aggregation Pipeline

Pipeline Stages

The aggregation pipeline processes documents through a sequence of stages, transforming data at each step. It is MongoDB's most powerful data transformation tool.

const results = await Post.aggregate([
  // Stage 1: Filter
  { $match: { published: true, createdAt: { $gte: new Date('2026-01-01') } } },

  // Stage 2: Join with users collection
  { $lookup: {
      from: 'users',
      localField: 'authorId',
      foreignField: '_id',
      as: 'author',
      pipeline: [{ $project: { name: 1, email: 1 } }]
  }},
  { $unwind: '$author' },

  // Stage 3: Add computed fields
  { $addFields: {
      authorName: '$author.name',
      readTime: { $ceil: { $divide: [{ $strLenCP: '$content' }, 200] } },
      wordCount: { $size: { $split: ['$content', ' '] } }
  }},

  // Stage 4: Group and aggregate
  { $group: {
      _id: '$authorId',
      authorName: { $first: '$authorName' },
      totalPosts: { $sum: 1 },
      totalViews: { $sum: '$viewCount' },
      avgReadTime: { $avg: '$readTime' },
      latestPost: { $max: '$createdAt' }
  }},

  // Stage 5: Sort by total views
  { $sort: { totalViews: -1 } },

  // Stage 6: Limit results
  { $limit: 10 },

  // Stage 7: Reshape output
  { $project: {
      _id: 0,
      authorId: '$_id',
      authorName: 1,
      totalPosts: 1,
      totalViews: 1,
      avgReadTime: { $round: ['$avgReadTime', 1] }
  }}
]);

Pagination with Aggregation

async function paginatedPosts(page = 1, limit = 20) {
  const skip = (page - 1) * limit;

  const [posts, [{ total }]] = await Promise.all([
    Post.aggregate([
      { $match: { published: true } },
      { $sort: { createdAt: -1 } },
      { $skip: skip },
      { $limit: limit },
      { $lookup: { from: 'users', localField: 'authorId', foreignField: '_id', as: 'author' } },
      { $unwind: '$author' },
      { $project: { title: 1, slug: 1, 'author.name': 1, createdAt: 1 } }
    ]),
    Post.aggregate([
      { $match: { published: true } },
      { $count: 'total' }
    ])
  ]);

  return { posts, total, page, totalPages: Math.ceil(total / limit) };
}

Common Aggregation Patterns

// Date grouping
await Order.aggregate([
  { $match: { createdAt: { $gte: startOfMonth } } },
  { $group: {
      _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } },
      totalRevenue: { $sum: '$amount' },
      orderCount: { $sum: 1 }
  }},
  { $sort: { _id: 1 } }
]);

// Unwind array and count
await Product.aggregate([
  { $unwind: '$tags' },
  { $group: { _id: '$tags', count: { $sum: 1 } } },
  { $sort: { count: -1 } },
  { $limit: 10 }
]);

// Bucket into ranges
await Product.aggregate([
  { $bucket: {
      groupBy: '$price',
      boundaries: [0, 25, 50, 100, Infinity],
      default: 'Other',
      output: { count: { $sum: 1 }, products: { $push: '$name' } }
  }}
]);

Indexing and Performance

Indexing and Performance

Index Types

MongoDB supports several index types to optimize query performance. Without indexes, MongoDB performs a collection scan (COLLSCAN) which reads every document.

// Single field index
userSchema.index({ email: 1 }, { unique: true });

// Compound index
postSchema.index({ authorId: 1, createdAt: -1 });

// Text index for full-text search
postSchema.index({ title: 'text', content: 'text' });

// TTL index: auto-delete documents after time
sessionSchema.index({ createdAt: 1 }, { expireAfterSeconds: 86400 });

// Sparse index: only index documents with the field
userSchema.index({ resetToken: 1 }, { sparse: true });

// Unique compound index
postSchema.index({ authorId: 1, slug: 1 }, { unique: true });

// Wildcard index: index all fields matching a pattern
logSchema.index({ 'metadata.$**': 1 });

// Hashed index for sharding
collectionSchema.index({ email: 'hashed' });

Performance Analysis

// Check query execution plan
const explain = await Post.find({ authorId: userId }).explain('executionStats');
console.log(explain.executionStats);

// Look for:
// totalDocsExamined: high number = needs index
// executionTimeMillis: > 100ms = optimize
// stage: 'IXSCAN' = using index (good), 'COLLSCAN' = full scan (bad)

// Count scans vs execution
const plan = await User.find({ age: { $gte: 18 } }).explain('executionStats');
console.log('Docs examined:', plan.executionStats.totalDocsExamined);
console.log('Docs returned:', plan.executionStats.nReturned);
console.log('Execution time:', plan.executionStats.executionTimeMillis, 'ms');

Common Anti-Patterns

// BAD: Unbounded array growth
const user = await User.findById(id);
user.searchHistory.push({ query, date: new Date() });  // array grows forever
await user.save();

// GOOD: Separate collection for bounded data
await SearchHistory.create({ userId: id, query, date: new Date() });

// BAD: Large document with frequently accessed subset
// User document has 500KB of profile data, but you only need name

// GOOD: Use projection to select only needed fields
const users = await User.find({}).select('name email');

// BAD: Regex without anchor
await Post.find({ title: /react/ });  // scans all documents

// GOOD: Text index or anchored regex
await Post.find({ $text: { $search: 'react' } });
await Post.find({ title: { $regex: /^react/ } });

Mongoose Performance

// Enable Mongoose debug in development
mongoose.set('debug', true);

// Lean queries: return plain objects instead of Mongoose documents
const users = await User.find({ active: true }).lean();  // 50% faster

// Select only needed fields
const names = await User.find({}).select('name email').lean();

// Use cursor for large result sets
const cursor = Post.find({}).cursor();
for await (const post of cursor) {
  await processPost(post);  // memory efficient
}

// Batch operations
await User.bulkWrite([
  { updateOne: { filter: { _id: id1 }, update: { $inc: { credits: 10 } } } },
  { updateOne: { filter: { _id: id2 }, update: { $inc: { credits: 20 } } } },
]);

Relationships and Population

Relationships and Population

Document References

MongoDB does not enforce foreign key constraints at the database level. Instead, references are maintained in application code, and Mongoose provides the populate() method to resolve them.

// Define referenced schemas
const authorSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  bio: String,
  avatarUrl: String
});

const commentSchema = new mongoose.Schema({
  postId: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', required: true },
  authorId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  parentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Comment', default: null },
  content: { type: String, required: true, maxlength: 2000 },
  likes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
}, { timestamps: true });

// Index for efficient comment queries
commentSchema.index({ postId: 1, createdAt: -1 });
commentSchema.index({ authorId: 1, createdAt: -1 });

Population Patterns

// Basic population
const post = await Post.findById(postId)
  .populate('authorId', 'name email avatarUrl')  // select specific fields
  .lean();

// Deep population (nested references)
const comments = await Comment.find({ postId })
  .populate({
    path: 'authorId',
    select: 'name avatarUrl',
    model: 'User'
  })
  .populate({
    path: 'parentId',
    select: 'content authorId',
    populate: { path: 'authorId', select: 'name' }
  })
  .sort({ createdAt: -1 })
  .lean();

// Virtual population (not stored, computed on access)
const postSchema = new mongoose.Schema({
  authorId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  title: String
});

postSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'postId',
  count: true  // returns count instead of documents
});

const post = await Post.findById(postId).populate('comments');
console.log(post.comments); // just a number if count: true

Embedded Relationships

// Embed subdocuments for 1-to-few relationships
const orderSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  items: [{
    productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' },
    name: String,
    price: Number,
    quantity: { type: Number, min: 1 }
  }],
  shippingAddress: {
    street: String,
    city: String,
    state: String,
    zip: String
  },
  totalAmount: { type: Number, required: true },
  status: { type: String, enum: ['pending', 'shipped', 'delivered', 'cancelled'], default: 'pending' }
}, { timestamps: true });

// Efficient: query by embedded field
const orders = await Order.find({ 'shippingAddress.state': 'OR' })
  .select('items totalAmount status')
  .lean();

Quiz

1. When should you embed subdocuments in MongoDB versus using a separate collection with references?

Question 1 options

2. What is the difference between `lean()` and regular Mongoose queries?

Question 2 options

3. What does the `$lookup` stage do in MongoDB's aggregation pipeline?

Question 3 options

Flashcards

Question

What is MongoDB?

Answer

MongoDB covers important concepts and best practices.

Question

What is MongoDB?

Answer

MongoDB covers important concepts and best practices.

Question

What is MongoDB?

Answer

MongoDB covers important concepts and best practices.

Revision Notes

Key Takeaways

  • 1. Embed for 1-to-few relationships accessed together; reference for unbounded or independently queried data
  • 2. Always add indexes on fields used in queries, sorts, and joins — without them MongoDB performs full collection scans
  • 3. Use lean() for read-only queries to reduce memory and improve performance by 50%
  • 4. The aggregation pipeline is MongoDB's most powerful tool for data transformation, equivalent to SQL GROUP BY with JOINs
  • 5. Mongoose middleware (pre/post hooks) enables automatic slug generation, validation, and audit logging
  • 6. Soft deletes with deletedAt field preserve data while excluding it from queries

Interview Tips

  • Explain the trade-off between embedding and referencing with specific examples (e.g., blog post comments vs. user profile addresses)
  • Describe the aggregation pipeline with a real-world example like analytics: filter by date range, group by category, compute averages
  • Know how to debug slow queries using explain('executionStats') and look for COLLSCAN vs IXSCAN
  • Discuss the N+1 problem in MongoDB and how populate() or aggregation $lookup solves it
  • Explain when to use lean() vs. full Mongoose documents and the implications for virtuals and middleware
  • Understand MongoDB's atomic operations at the document level and how they differ from SQL transactions

Cheat Sheet

Mongoose Query Patterns

Model.findById(id).populate('ref').lean() // Fast with resolved references,Model.find({ field: { $in: arr } }).select('field1 field2') // Filter and project,Model.findOneAndUpdate({ _id }, { $set: data }, { new: true, runValidators: true }),Model.aggregate([{ $match }, { $group }, { $sort }]) // Data transformation

Aggregation Stage Reference

$match → WHERE clause (filter documents),$group → GROUP BY with accumulators ($sum, $avg, $min, $max),$lookup → LEFT OUTER JOIN with another collection,$project → SELECT specific fields or computed values,$unwind → Flatten array into individual documents,$addFields → Add computed fields without grouping

Index Creation

Schema.index({ field: 1 }) // Ascending index,Schema.index({ field1: 1, field2: -1 }) // Compound index,Schema.index({ field: 'text' }) // Full-text search index,Schema.index({ createdAt: 1 }, { expireAfterSeconds: 86400 }) // TTL auto-delete,await collection.getIndexes() // List all indexes,await collection.dropIndex({ fieldName: 1 }) // Remove an index