MongoDB Interview Questions: The NoSQL Guide for Node.js Developers

·15 min read
mongodbinterview-questionsdatabasenodejsmongoosebackend

MongoDB powers over 35% of modern Node.js applications, yet "SQL vs NoSQL" debates still dominate interview rooms. The real question isn't which is better—it's whether you understand when MongoDB is the right choice and how to use it effectively. Here's how to demonstrate that understanding.

The 30-Second Answer

When the interviewer asks "When would you use MongoDB over a relational database?", here's your concise answer:

"I choose MongoDB when I need flexible schemas, horizontal scaling, or when my data naturally fits a document model—like user profiles with varying fields, content management, or real-time analytics. I stick with SQL when I need complex joins across many tables, strict referential integrity, or ACID transactions spanning multiple records. In Node.js apps, MongoDB's JSON-like documents map naturally to JavaScript objects, making it a productive choice for rapid development."

That's it. Wait for follow-up questions.

The 2-Minute Answer (If They Want More)

If they ask you to elaborate:

"The decision comes down to data structure and access patterns.

Choose MongoDB when:

  • Your schema evolves frequently or varies per document
  • You read/write entire documents together
  • You need to scale horizontally across servers
  • Your data is hierarchical or document-like (user profiles, product catalogs, content)

Choose SQL when:

  • You have complex relationships between entities
  • You need to query data in unpredictable ways with JOINs
  • Transactions spanning multiple tables are critical
  • Data integrity constraints are paramount

In practice, many applications use both. I might use PostgreSQL for financial transactions and user authentication, but MongoDB for activity feeds or content storage where flexibility matters more than strict consistency."

MongoDB Fundamentals

Documents and Collections

// MongoDB stores data as BSON documents (JSON-like)
// Collection = table, Document = row
 
// A document in the "users" collection
{
  _id: ObjectId("507f1f77bcf86cd799439011"),
  name: "Sarah Chen",
  email: "sarah@example.com",
  profile: {
    bio: "Full-stack developer",
    skills: ["Node.js", "MongoDB", "React"]
  },
  createdAt: ISODate("2024-01-15T10:30:00Z")
}

Key differences from SQL:

  • No fixed schema—documents in the same collection can have different fields
  • Nested objects and arrays are first-class citizens
  • No JOINs by default—data is often denormalized

Mongoose Schema Definition

const mongoose = require('mongoose');
 
const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required'],
    trim: true,
    maxlength: 100
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, 'Invalid email format']
  },
  password: {
    type: String,
    required: true,
    minlength: 8,
    select: false  // Don't include in queries by default
  },
  role: {
    type: String,
    enum: ['user', 'admin', 'moderator'],
    default: 'user'
  },
  profile: {
    bio: { type: String, maxlength: 500 },
    avatar: String,
    skills: [String]
  },
  loginAttempts: { type: Number, default: 0 },
  lockUntil: Date
}, {
  timestamps: true,  // Adds createdAt and updatedAt
  toJSON: { virtuals: true }
});
 
// Indexes for query performance
userSchema.index({ email: 1 });
userSchema.index({ 'profile.skills': 1 });
userSchema.index({ createdAt: -1 });
 
// Virtual property (not stored in DB)
userSchema.virtual('isLocked').get(function() {
  return this.lockUntil && this.lockUntil > Date.now();
});
 
// Instance method
userSchema.methods.comparePassword = async function(candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};
 
// Static method
userSchema.statics.findByEmail = function(email) {
  return this.findOne({ email: email.toLowerCase() });
};
 
// Pre-save middleware
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});
 
const User = mongoose.model('User', userSchema);

Embedding vs Referencing

This is the most important schema design decision in MongoDB.

When to Embed (Denormalize)

// GOOD: Embed addresses in user document
// - Accessed together with user
// - One-to-few relationship
// - Rarely updated independently
const userSchema = new mongoose.Schema({
  name: String,
  addresses: [{
    street: String,
    city: String,
    zipCode: String,
    isDefault: Boolean
  }]
});
 
// Query returns everything in one call
const user = await User.findById(userId);
console.log(user.addresses[0].city);

When to Reference (Normalize)

// GOOD: Reference orders separately
// - Accessed independently
// - One-to-many relationship (user has many orders)
// - Orders grow unboundedly
const orderSchema = new mongoose.Schema({
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
    index: true
  },
  items: [{
    product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' },
    quantity: Number,
    price: Number
  }],
  total: Number,
  status: String
});
 
// Populate to join data
const orders = await Order.find({ user: userId })
  .populate('user', 'name email')
  .populate('items.product', 'name price');

Decision Framework

FactorEmbedReference
RelationshipOne-to-fewOne-to-many, Many-to-many
Access patternAlways togetherOften separate
Update frequencyRarely changesChanges independently
Document sizeSmall (< 16MB)Could grow large
Data duplicationAcceptableProblematic

Interview insight: "I'd embed a user's addresses because they're always fetched with the user profile and there are only a few. But I'd reference orders because a user could have thousands, they're queried independently, and they update frequently."

CRUD Operations

Create

// Single document
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com',
  password: 'securePassword123'
});
 
// Multiple documents
const users = await User.insertMany([
  { name: 'Alice', email: 'alice@example.com' },
  { name: 'Bob', email: 'bob@example.com' }
]);
 
// With validation handling
try {
  const user = await User.create(userData);
} catch (error) {
  if (error.code === 11000) {
    // Duplicate key error (unique constraint)
    throw new Error('Email already exists');
  }
  if (error.name === 'ValidationError') {
    // Mongoose validation failed
    const messages = Object.values(error.errors).map(e => e.message);
    throw new Error(messages.join(', '));
  }
  throw error;
}

Read

// Find one
const user = await User.findById(id);
const user = await User.findOne({ email: 'john@example.com' });
 
// Find many with query builders
const users = await User.find({ role: 'admin' })
  .select('name email createdAt')     // Only these fields
  .sort({ createdAt: -1 })            // Newest first
  .skip(20)                           // Pagination offset
  .limit(10)                          // Page size
  .lean();                            // Return plain objects (faster)
 
// Complex queries
const users = await User.find({
  createdAt: { $gte: new Date('2024-01-01') },
  'profile.skills': { $in: ['Node.js', 'MongoDB'] },
  role: { $ne: 'admin' }
});
 
// Text search (requires text index)
const results = await Product.find(
  { $text: { $search: 'wireless bluetooth' } },
  { score: { $meta: 'textScore' } }
).sort({ score: { $meta: 'textScore' } });

Update

// Update one document
const result = await User.updateOne(
  { _id: userId },
  { $set: { 'profile.bio': 'Updated bio' } }
);
 
// Find and update (returns the document)
const user = await User.findByIdAndUpdate(
  userId,
  { $inc: { loginAttempts: 1 } },
  { new: true, runValidators: true }  // Return updated doc, run validators
);
 
// Update operators
await User.updateOne({ _id: userId }, {
  $set: { name: 'New Name' },           // Set field value
  $unset: { tempField: '' },            // Remove field
  $inc: { loginCount: 1 },              // Increment number
  $push: { 'profile.skills': 'GraphQL' }, // Add to array
  $pull: { 'profile.skills': 'jQuery' },  // Remove from array
  $addToSet: { tags: 'verified' }       // Add to array if not exists
});
 
// Bulk updates
await User.updateMany(
  { lastLogin: { $lt: new Date('2023-01-01') } },
  { $set: { status: 'inactive' } }
);

Delete

// Delete one
await User.deleteOne({ _id: userId });
const user = await User.findByIdAndDelete(userId);
 
// Delete many
const result = await User.deleteMany({ status: 'inactive' });
console.log(`Deleted ${result.deletedCount} users`);
 
// Soft delete pattern (preferred for most apps)
const userSchema = new mongoose.Schema({
  // ... other fields
  deletedAt: Date
});
 
userSchema.pre(/^find/, function() {
  this.where({ deletedAt: null });
});
 
userSchema.methods.softDelete = function() {
  this.deletedAt = new Date();
  return this.save();
};

Aggregation Pipeline

The aggregation pipeline is MongoDB's most powerful feature—and a common interview topic.

Basic Pipeline

// Sales report: total revenue by product category
const report = await Order.aggregate([
  // Stage 1: Filter to completed orders this year
  {
    $match: {
      status: 'completed',
      createdAt: { $gte: new Date('2024-01-01') }
    }
  },
  // Stage 2: Unwind the items array (one doc per item)
  { $unwind: '$items' },
  // Stage 3: Lookup product details
  {
    $lookup: {
      from: 'products',
      localField: 'items.product',
      foreignField: '_id',
      as: 'productInfo'
    }
  },
  // Stage 4: Flatten the lookup result
  { $unwind: '$productInfo' },
  // Stage 5: Group by category
  {
    $group: {
      _id: '$productInfo.category',
      totalRevenue: { $sum: { $multiply: ['$items.quantity', '$items.price'] } },
      totalOrders: { $sum: 1 },
      avgOrderValue: { $avg: { $multiply: ['$items.quantity', '$items.price'] } }
    }
  },
  // Stage 6: Sort by revenue descending
  { $sort: { totalRevenue: -1 } },
  // Stage 7: Reshape output
  {
    $project: {
      category: '$_id',
      totalRevenue: { $round: ['$totalRevenue', 2] },
      totalOrders: 1,
      avgOrderValue: { $round: ['$avgOrderValue', 2] },
      _id: 0
    }
  }
]);

Common Aggregation Stages

// $match - Filter documents (like WHERE)
{ $match: { status: 'active', age: { $gte: 18 } } }
 
// $group - Aggregate values (like GROUP BY)
{ $group: {
    _id: '$category',
    count: { $sum: 1 },
    avgPrice: { $avg: '$price' },
    maxPrice: { $max: '$price' },
    items: { $push: '$name' }  // Collect into array
}}
 
// $project - Reshape documents (like SELECT)
{ $project: {
    name: 1,
    email: 1,
    fullName: { $concat: ['$firstName', ' ', '$lastName'] },
    year: { $year: '$createdAt' }
}}
 
// $lookup - Join collections (like LEFT JOIN)
{ $lookup: {
    from: 'orders',
    localField: '_id',
    foreignField: 'userId',
    as: 'userOrders'
}}
 
// $unwind - Flatten arrays
{ $unwind: '$tags' }  // One document per tag
 
// $sort, $skip, $limit - Pagination
{ $sort: { createdAt: -1 } },
{ $skip: 20 },
{ $limit: 10 }
 
// $facet - Multiple pipelines in parallel
{ $facet: {
    results: [{ $skip: 0 }, { $limit: 10 }],
    totalCount: [{ $count: 'count' }]
}}

Indexing for Performance

Creating Indexes

// In Mongoose schema
const productSchema = new mongoose.Schema({
  name: { type: String, index: true },        // Single field
  sku: { type: String, unique: true },        // Unique index
  category: String,
  price: Number,
  tags: [String],
  description: String
});
 
// Compound index (queries using both fields)
productSchema.index({ category: 1, price: -1 });
 
// Text index for search
productSchema.index({ name: 'text', description: 'text' });
 
// TTL index (auto-delete after time)
const sessionSchema = new mongoose.Schema({
  userId: ObjectId,
  expiresAt: { type: Date, index: { expires: 0 } }  // Delete when expiresAt passes
});
 
// Programmatically
await Product.collection.createIndex({ category: 1, price: -1 });

Index Strategy

// Check if query uses index
const explanation = await User.find({ email: 'test@example.com' })
  .explain('executionStats');
 
console.log(explanation.executionStats.executionStages.stage);
// IXSCAN = using index (good)
// COLLSCAN = collection scan (bad for large collections)
 
// Index intersection vs compound index
// If you query: { category: 'electronics', brand: 'Apple' }
 
// Option 1: Two single indexes (MongoDB may intersect)
productSchema.index({ category: 1 });
productSchema.index({ brand: 1 });
 
// Option 2: Compound index (more efficient for this specific query)
productSchema.index({ category: 1, brand: 1 });
 
// Compound index order matters!
// Index { a: 1, b: 1, c: 1 } supports:
// - Queries on { a }
// - Queries on { a, b }
// - Queries on { a, b, c }
// But NOT queries on just { b } or { c }

Interview insight: "I'd check slow queries with explain() and create compound indexes matching our most common query patterns. Index order follows the ESR rule—Equality, Sort, Range—for optimal performance."

Connection Management

// Connection with best practices
const mongoose = require('mongoose');
 
const connectDB = async () => {
  try {
    await mongoose.connect(process.env.MONGODB_URI, {
      maxPoolSize: 10,           // Connection pool size
      serverSelectionTimeoutMS: 5000,
      socketTimeoutMS: 45000,
    });
    console.log('MongoDB connected');
  } catch (error) {
    console.error('MongoDB connection error:', error);
    process.exit(1);
  }
};
 
// Handle connection events
mongoose.connection.on('error', err => {
  console.error('MongoDB error:', err);
});
 
mongoose.connection.on('disconnected', () => {
  console.warn('MongoDB disconnected. Attempting reconnect...');
});
 
// Graceful shutdown
process.on('SIGINT', async () => {
  await mongoose.connection.close();
  console.log('MongoDB connection closed due to app termination');
  process.exit(0);
});

Transactions

// Multi-document transaction (MongoDB 4.0+)
const session = await mongoose.startSession();
 
try {
  session.startTransaction();
 
  // All operations use the same session
  const user = await User.create([{ name: 'John', balance: 1000 }], { session });
 
  await Account.findByIdAndUpdate(
    fromAccountId,
    { $inc: { balance: -100 } },
    { session }
  );
 
  await Account.findByIdAndUpdate(
    toAccountId,
    { $inc: { balance: 100 } },
    { session }
  );
 
  // Commit if all succeeded
  await session.commitTransaction();
} catch (error) {
  // Rollback on any error
  await session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}
 
// Using withTransaction helper (recommended)
await session.withTransaction(async () => {
  await Account.findByIdAndUpdate(fromId, { $inc: { balance: -100 } }, { session });
  await Account.findByIdAndUpdate(toId, { $inc: { balance: 100 } }, { session });
});

Interview insight: "I minimize transaction usage by designing schemas that keep related data in single documents. When I do need transactions—like fund transfers—I use the withTransaction helper for automatic retry on transient errors."

Common Interview Questions

"How do you handle the N+1 query problem in MongoDB?"

"N+1 happens when you fetch a list then query each item's related data separately. In MongoDB, I solve it with:

  1. Embedding - If data is always accessed together, embed it
  2. populate() - Mongoose batches reference lookups
  3. $lookup in aggregation - Server-side join
  4. DataLoader pattern - Batch and cache lookups in the application layer"

"MongoDB has no schema—how do you ensure data quality?"

"While MongoDB itself is schema-flexible, I enforce schemas at the application level with Mongoose. It provides:

  • Type validation and casting
  • Required fields and custom validators
  • Pre/post hooks for business logic
  • Default values

I also use MongoDB's JSON Schema validation for critical collections as a database-level safety net."

"How do you scale MongoDB?"

"MongoDB scales through:

  • Replica sets for read scaling and high availability (primary + secondaries)
  • Sharding for horizontal write scaling across multiple servers
  • Read preferences to route reads to secondaries

For most applications, a properly indexed single replica set handles millions of documents. I'd only shard when data exceeds a single server's capacity."

"Design a schema for a blog platform"

// Users - standalone collection
const userSchema = new Schema({
  username: { type: String, unique: true },
  email: { type: String, unique: true },
  passwordHash: String,
  profile: {
    displayName: String,
    bio: String,
    avatar: String
  }
});
 
// Posts - references author, embeds limited comments
const postSchema = new Schema({
  author: { type: ObjectId, ref: 'User', index: true },
  title: String,
  slug: { type: String, unique: true },
  content: String,
  tags: { type: [String], index: true },
  status: { type: String, enum: ['draft', 'published'] },
  // Embed recent comments (limit to prevent unbounded growth)
  recentComments: [{
    author: { type: ObjectId, ref: 'User' },
    content: String,
    createdAt: Date
  }],
  commentCount: { type: Number, default: 0 },
  viewCount: { type: Number, default: 0 }
}, { timestamps: true });
 
// Full comments - separate collection for scalability
const commentSchema = new Schema({
  post: { type: ObjectId, ref: 'Post', index: true },
  author: { type: ObjectId, ref: 'User' },
  content: String,
  parentComment: { type: ObjectId, ref: 'Comment' }  // For threading
}, { timestamps: true });
 
// Indexes for common queries
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ tags: 1, status: 1 });
postSchema.index({ slug: 1 }, { unique: true });

Quick Reference

SQLMongoDBMongoose
TableCollectionModel
RowDocumentDocument instance
ColumnFieldSchema field
Primary Key_id (ObjectId)_id
Foreign KeyReference (ObjectId)ref + populate()
JOIN$lookup / populate.populate()
GROUP BY$group.aggregate()
INDEXcreateIndex()schema.index()
Transactionsession.withTransaction()session.withTransaction()

Common Mistakes to Avoid

  1. Storing ObjectIds as strings - Use mongoose.Types.ObjectId
  2. Not indexing query fields - Leads to slow collection scans
  3. Unbounded arrays - Embed with limits or reference separately
  4. Ignoring connection pooling - Default pool size may be too small
  5. Over-using populate() - Multiple populates can be slow; consider aggregation
  6. Not using lean() - For read-only queries, lean() is much faster

Related Articles

If you found this helpful, check out these related guides:

Ready for More Database Interview Questions?

This is just one topic from our complete backend interview prep guide. Get access to 50+ questions covering:

  • SQL deep dive (transactions, optimization, normalization)
  • PostgreSQL and MySQL specifics
  • Database design patterns
  • Caching strategies with Redis
  • Database scaling and replication

Get Full Access to All Backend Questions


Written by the EasyInterview team, based on real interview experience from 12+ years in tech and hundreds of technical interviews conducted at companies like BNY Mellon, UBS, and leading fintech firms.

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides