RSRobince Studio

Top 50 Mongoose interview questions and answers

The 50 questions interviewers ask most about Mongoose, with short, clear answers and code where it helps. The first 25 are the most common; the rest go deeper. Free, no sign-up.

All 50 questions

  1. What is Mongoose and why use it with MongoDB?
  2. What is a Schema in Mongoose?
  3. What is a Model in Mongoose and how do you create one?
  4. What is the difference between a Schema and a Model?
  5. How do you connect to MongoDB using Mongoose?
  6. What data types does Mongoose support?
  7. How does validation work in Mongoose?
  8. How do you set default values for fields?
  9. What is the difference between `required` and `unique` in Mongoose?
  10. What are the common methods for creating documents?
  11. How do you read documents with `find`, `findOne` and `findById`?
  12. How do you update documents in Mongoose?
  13. What is the difference between `save()` and `updateOne()`?
  14. How do you delete documents in Mongoose?
  15. What is `populate()` in Mongoose?
  16. What is middleware (hooks) in Mongoose? Explain pre and post hooks.
  17. What are virtuals in Mongoose?
  18. What is the difference between instance methods and static methods?
  19. How do you create indexes in Mongoose?
  20. What does `lean()` do and when should you use it?
  21. How do timestamps work in Mongoose?
  22. What are subdocuments in Mongoose?
  23. What are discriminators in Mongoose?
  24. How do transactions work in Mongoose?
  25. How do you build queries in Mongoose, and how do queries differ from promises?
  26. How would you solve the N+1 query problem when using `populate()`?
  27. What is the difference between `populate()` and `$lookup` in an aggregation?
  28. What are virtual populate and when would you use it?
  29. What is the difference between embedding and referencing, and how do you choose?
  30. What is the output of this code, and why does it not update the field?
  31. Why can an array field with a default value cause surprising behavior, and how do you control it?
  32. How do you handle a duplicate key error (code 11000) gracefully?
  33. How do you implement pagination in Mongoose, and what are the trade-offs?
  34. How do you do a full-text or partial search in Mongoose?
  35. What is the difference between `findOneAndUpdate()` and `findByIdAndUpdate()` with `upsert`?
  36. How do you update a document atomically, such as incrementing a counter, without race conditions?
  37. What is optimistic concurrency in Mongoose and how does the `__v` version key help?
  38. What happens if you use an arrow function in a Mongoose hook or method, and why is it a bug?
  39. Why do pre('save') hooks not run for `updateOne()` or `insertMany()`, and how do you cover those cases?
  40. How do you write custom async validators and validate across multiple fields?
  41. What does `strict` mode do in a schema, and what is `strictQuery`?
  42. How do you prevent NoSQL injection in Mongoose queries?
  43. How do you manage multiple database connections in one Node.js app?
  44. How does connection pooling work in Mongoose, and how would you tune it?
  45. How do you use Mongoose safely in a serverless environment such as AWS Lambda?
  46. What does Mongoose do with commands issued before the connection is ready (buffering)?
  47. How do you use `bulkWrite()` and when is it better than many separate updates?
  48. How do you handle large result sets without running out of memory?
  49. What are change streams and how can you use them with Mongoose?
  50. How would you structure and test a Mongoose-based application for production?

1. What is Mongoose and why use it with MongoDB?

Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It sits on top of the official MongoDB driver and adds schemas, validation, type casting, middleware, population and a chainable query API. MongoDB itself is schemaless, so Mongoose gives your application a predictable structure and cleaner, more maintainable data-access code.

2. What is a Schema in Mongoose?

A schema is an object that defines the structure of documents in a collection: field names, types, defaults, validators, indexes and options. It does not talk to the database itself. You compile it into a model, and the model is what you use to query and save documents.

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  age: Number,
  createdAt: { type: Date, default: Date.now }
});

3. What is a Model in Mongoose and how do you create one?

A model is a constructor compiled from a schema that represents a MongoDB collection and provides the API for creating, querying, updating and deleting documents. You create it with mongoose.model('User', userSchema). Mongoose lowercases and pluralizes the name, so User maps to the users collection by default.

const User = mongoose.model('User', userSchema);
const user = await User.create({ name: 'Asha', age: 30 });

4. What is the difference between a Schema and a Model?

A schema describes the shape and rules of your data, while a model is the class built from that schema that actually interacts with a collection. The schema is a blueprint; the model gives you methods like find(), create() and save(). Instances of a model are documents.

5. How do you connect to MongoDB using Mongoose?

You call mongoose.connect() with a connection string, which returns a promise, so you can await it and handle errors with try/catch. Since Mongoose 6 the old options such as useNewUrlParser are no longer needed. You can also listen to connection events like connected, error and disconnected on mongoose.connection.

try {
  await mongoose.connect(process.env.MONGO_URI);
  console.log('Connected');
} catch (err) {
  console.error(err);
  process.exit(1);
}

6. What data types does Mongoose support?

Mongoose schema types include String, Number, Date, Boolean, Buffer, ObjectId, Array, Mixed, Map, Decimal128, UUID and BigInt. Nested objects and arrays of subdocuments are also supported. Values are cast to the declared type where possible, and a failed cast raises a CastError.

7. How does validation work in Mongoose?

Validation is defined in the schema and runs automatically before a document is saved. Built-in validators include required, min, max, minlength, maxlength, enum and match. You can also supply a custom validate function. Failures throw a ValidationError that lists each invalid path. Validators do not run on update queries unless you pass runValidators: true.

const schema = new mongoose.Schema({
  email: {
    type: String,
    required: [true, 'Email is required'],
    match: /^\S+@\S+\.\S+$/
  }
});

8. How do you set default values for fields?

Use the default option in the schema, either as a fixed value or as a function that is evaluated when the document is created. Functions such as Date.now are passed without parentheses so they run per document rather than once at startup. Defaults apply only when the field is undefined.

9. What is the difference between `required` and `unique` in Mongoose?

required is a validator that Mongoose checks in JavaScript before saving, and it fails if the value is missing. unique is not a validator; it tells Mongoose to create a unique index in MongoDB, and duplicate violations come back from the database as an error with code 11000. The index must finish building before uniqueness is enforced.

10. What are the common methods for creating documents?

You can use Model.create(), which builds and saves one or many documents, new Model(data).save(), which gives you a document to modify first, and Model.insertMany() for efficient bulk inserts. create() and save() run validation and middleware, while insertMany() validates but skips save middleware.

11. How do you read documents with `find`, `findOne` and `findById`?

find() returns an array of all documents matching a filter, findOne() returns the first match or null, and findById(id) is shorthand for findOne({ _id: id }). All return query objects that you can chain with select(), sort(), limit() and skip(), and await to execute.

const users = await User.find({ age: { $gte: 18 } })
  .select('name age')
  .sort({ age: -1 })
  .limit(10);

12. How do you update documents in Mongoose?

You can load a document, change it and call save(), which runs full validation and middleware, or use updateOne(), updateMany() and findOneAndUpdate() for atomic updates in a single database call. Update queries skip document middleware and validators unless you opt in with runValidators: true. Using { new: true } returns the updated document from findOneAndUpdate.

13. What is the difference between `save()` and `updateOne()`?

save() works on a document instance, runs validation and save middleware, and sends only the modified fields. updateOne() is a query that updates directly in the database without loading the document, so it is faster but skips document validation and save hooks by default. Use save() when you need business logic to run.

14. How do you delete documents in Mongoose?

Use deleteOne() or deleteMany() with a filter, findByIdAndDelete() or findOneAndDelete() to delete and get the removed document back, or doc.deleteOne() on an instance. The older remove() method was removed in Mongoose 7, so interviewers expect the newer methods.

15. What is `populate()` in Mongoose?

populate() replaces a stored ObjectId reference with the actual document from another collection, similar to a join. You declare the relationship with ref in the schema and call populate() on the query. You can select fields, filter, and populate nested paths. Each populate runs an additional query, so use it thoughtfully.

const postSchema = new mongoose.Schema({
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
});
const posts = await Post.find().populate('author', 'name email');

16. What is middleware (hooks) in Mongoose? Explain pre and post hooks.

Middleware are functions that run before (pre) or after (post) an operation such as save, validate, find, updateOne or deleteOne. Common uses are hashing passwords, logging and cascading deletes. Pre hooks receive next or can be async functions, and a thrown error stops the operation. Hooks are defined on the schema before the model is compiled.

userSchema.pre('save', async function () {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 10);
  }
});

17. What are virtuals in Mongoose?

Virtuals are document properties that are computed from other fields and are not stored in MongoDB. You define getters, and optionally setters, on the schema, such as a fullName built from first and last names. They are not included in JSON output or toObject() by default, so set toJSON: { virtuals: true }. You cannot query by a virtual field.

18. What is the difference between instance methods and static methods?

Instance methods are defined on schema.methods and are available on individual documents, where this is the document. Static methods are defined on schema.statics and are called on the model itself, where this is the model. Use instance methods for per-document logic and statics for collection-level helpers.

userSchema.methods.greet = function () { return `Hi ${this.name}`; };
userSchema.statics.findByName = function (name) {
  return this.find({ name });
};

19. How do you create indexes in Mongoose?

Add index: true or unique: true to a field, or call schema.index() for compound, text or TTL indexes. Mongoose builds them on startup via autoIndex, which is usually disabled in production, where you call Model.syncIndexes() or create indexes through migrations. Good indexes speed up queries but add write overhead.

userSchema.index({ lastName: 1, firstName: 1 });

20. What does `lean()` do and when should you use it?

lean() makes a query return plain JavaScript objects instead of full Mongoose documents. This skips hydration, so it uses less memory and is noticeably faster for large read-only results. The trade-off is that lean results have no save(), virtuals, getters or instance methods, so use it for read-only endpoints.

const users = await User.find().lean();

21. How do timestamps work in Mongoose?

Setting { timestamps: true } in schema options makes Mongoose add and maintain createdAt and updatedAt date fields automatically. updatedAt is refreshed on save() and on update methods such as findOneAndUpdate. You can rename the fields by passing an object with createdAt and updatedAt keys.

const schema = new mongoose.Schema({ title: String }, { timestamps: true });

22. What are subdocuments in Mongoose?

Subdocuments are documents embedded inside another document, either as a single nested schema or as an array of child schemas. Each subdocument gets its own _id by default, has its own validation and middleware, and is saved with its parent. They are a good fit for data that is always read together with the parent, and they avoid a populate query.

const addressSchema = new mongoose.Schema({ city: String, zip: String });
const userSchema = new mongoose.Schema({ addresses: [addressSchema] });

23. What are discriminators in Mongoose?

Discriminators are a schema inheritance mechanism that lets several models with overlapping schemas share one MongoDB collection. Mongoose stores a __t key to tell the types apart, so you can query the base model for all types or a child model for just one. They suit cases like Event with ClickedEvent and PurchasedEvent variants.

const Event = mongoose.model('Event', eventSchema);
const Click = Event.discriminator('Click', new mongoose.Schema({ url: String }));

24. How do transactions work in Mongoose?

Mongoose supports MongoDB multi-document transactions through sessions, and they require a replica set or sharded cluster. Use mongoose.startSession() with session.withTransaction() and pass the session option to every operation inside. If the callback throws, the transaction aborts and all changes roll back. Always end the session afterwards.

const session = await mongoose.startSession();
await session.withTransaction(async () => {
  await Account.updateOne({ _id: a }, { $inc: { bal: -50 } }, { session });
  await Account.updateOne({ _id: b }, { $inc: { bal: 50 } }, { session });
});
await session.endSession();

25. How do you build queries in Mongoose, and how do queries differ from promises?

A Mongoose query is a chainable object built with methods like where(), select(), sort(), limit(), skip() and populate(). It is lazy and runs only when you await it, call .exec() or pass a callback. Queries are thenable but not true promises, so .exec() returns a real promise with better stack traces. Executing the same query twice runs it twice.

const result = await User.where('age').gte(18).lt(65)
  .sort('-createdAt').limit(20).exec();

26. How would you solve the N+1 query problem when using `populate()`?

The N+1 problem appears when you fetch a list and then run one extra query per item to load related data. populate() avoids it by batching: it collects all referenced ids and loads them with a single extra $in query. Make sure you call populate() on the list query, not inside a loop, and select only the fields you need. For heavy joins, consider $lookup in an aggregation or embedding the data.

// Bad: one query per post
for (const p of posts) p.author = await User.findById(p.author);
// Good: two queries in total
const posts = await Post.find().populate('author', 'name');

27. What is the difference between `populate()` and `$lookup` in an aggregation?

populate() is a Mongoose feature that runs a second query and stitches results together in your Node.js process, while $lookup performs the join inside MongoDB in a single aggregation. $lookup can filter and group on joined data on the server, but it returns plain objects, not hydrated documents, and Mongoose does not cast pipeline stages. Use populate() for simple references and $lookup for reporting.

28. What are virtual populate and when would you use it?

Virtual populate lets you populate a field that is not stored on the document, usually the reverse side of a one-to-many relationship. You define a virtual with ref, localField and foreignField, so the parent does not need an ever-growing array of child ids. It avoids unbounded arrays and keeps the data normalized. Remember that virtuals must be enabled in toJSON to show up in output.

userSchema.virtual('posts', {
  ref: 'Post',
  localField: '_id',
  foreignField: 'author'
});
const user = await User.findById(id).populate('posts');

29. What is the difference between embedding and referencing, and how do you choose?

Embedding stores related data inside the parent document, while referencing stores an ObjectId and loads the related document separately with populate(). Embed when data is read together, is small and bounded, and rarely changes independently, such as addresses on a user. Reference when the related data is large, shared across parents or grows without limit, such as comments on a popular post. The 16 MB document limit is a hard ceiling for embedding.

30. What is the output of this code, and why does it not update the field?

If settings is declared as Mixed (or as a plain {}), Mongoose cannot detect the nested change, so save() writes nothing. The fix is to call doc.markModified('settings') before saving, or to define the nested fields explicitly in the schema so change tracking works. This is a classic pitfall of Schema.Types.Mixed.

const doc = await User.findById(id);
doc.settings.theme = 'dark';
await doc.save();

31. Why can an array field with a default value cause surprising behavior, and how do you control it?

Mongoose arrays default to an empty array, so every new document gets [] stored even if you never set it. This is convenient for pushing, but it bloats documents and breaks queries that check for a missing field. Set default: undefined on the array path to opt out. Also be aware that assigning a whole array replaces it, while push() and $push append atomically.

const schema = new mongoose.Schema({
  tags: { type: [String], default: undefined }
});

32. How do you handle a duplicate key error (code 11000) gracefully?

Catch the error from save() or create() and check err.code === 11000, then read err.keyValue to find which field collided and return a friendly message such as a 409 response. Do not rely on a pre-check with findOne(), because two concurrent requests can both pass it. The unique index is the only reliable guard against race conditions.

try {
  await User.create({ email });
} catch (err) {
  if (err.code === 11000) return res.status(409).json({ field: Object.keys(err.keyValue) });
  throw err;
}

33. How do you implement pagination in Mongoose, and what are the trade-offs?

Offset pagination uses skip((page - 1) * limit).limit(limit) and is simple, but skip has to walk past all skipped documents, so it gets slower on deep pages and can show duplicates when data changes. Cursor (keyset) pagination filters on the last seen value, such as _id: { $gt: lastId } with a sort on the same indexed field, and stays fast at any depth. Prefer cursors for large or infinite-scroll lists.

const items = await Post.find({ _id: { $gt: lastId } })
  .sort({ _id: 1 })
  .limit(20);

34. How do you do a full-text or partial search in Mongoose?

For word-based search, create a text index with schema.index({ title: 'text', body: 'text' }) and query with { $text: { $search: 'term' } }, optionally sorting by { $meta: 'textScore' }. A case-insensitive $regex works for substring matching but cannot use an index efficiently unless it is anchored at the start. For serious search features, use MongoDB Atlas Search or a dedicated engine.

35. What is the difference between `findOneAndUpdate()` and `findByIdAndUpdate()` with `upsert`?

findByIdAndUpdate(id, update) is shorthand for findOneAndUpdate({ _id: id }, update). With upsert: true, either one inserts a new document if nothing matches the filter, and Mongoose applies schema defaults only when you also set setDefaultsOnInsert, which is on by default in recent versions. Combine upsert with a unique index to avoid creating duplicates under concurrent calls.

36. How do you update a document atomically, such as incrementing a counter, without race conditions?

Use an update operator like $inc inside updateOne() or findOneAndUpdate() so MongoDB applies the change atomically on the server. Reading the document, changing the value in JavaScript and calling save() is a read-modify-write cycle, and two concurrent requests will overwrite each other. Atomic operators such as $inc, $push and $addToSet avoid that lost-update problem.

await Product.updateOne({ _id: id, stock: { $gt: 0 } }, { $inc: { stock: -1 } });

37. What is optimistic concurrency in Mongoose and how does the `__v` version key help?

Optimistic concurrency detects conflicting writes without locking. Mongoose adds a __v version key and increments it for array changes, and with optimisticConcurrency: true in schema options, save() checks that the version has not changed since you loaded the document. If another process updated it first, Mongoose throws a VersionError and you can reload and retry.

38. What happens if you use an arrow function in a Mongoose hook or method, and why is it a bug?

Arrow functions do not have their own this, so inside a hook, virtual getter, instance method or custom validator, this will not refer to the document or query. Code like this.password then throws or returns undefined. Always use a regular function for anything that relies on this. Async functions are fine as long as they are written with the function keyword.

// Wrong: this is not the document
schema.pre('save', async () => { this.updatedBy = 'x'; });
// Right
schema.pre('save', async function () { this.updatedBy = 'x'; });

39. Why do pre('save') hooks not run for `updateOne()` or `insertMany()`, and how do you cover those cases?

Document middleware like save only runs when you call save() on a document instance. updateOne() and findOneAndUpdate() are query operations with their own query middleware, where this is the query, and insertMany() has its own bulk hooks. If a rule must always apply, register the matching hook, such as schema.pre('findOneAndUpdate', ...), or enforce it with a database-level constraint.

40. How do you write custom async validators and validate across multiple fields?

A custom validator is a function in the validate option that returns a boolean or a promise, and async validators are supported by returning a promise. For cross-field rules, use a regular function so this is the document, or add a schema-level pre('validate') hook that reads several fields. Inside update validators, this is the query, so cross-field checks need extra care.

schema.path('endDate').validate(function (value) {
  return value > this.startDate;
}, 'endDate must be after startDate');

41. What does `strict` mode do in a schema, and what is `strictQuery`?

With strict: true, the default, Mongoose silently drops fields that are not in the schema when you create or update documents, which protects against unexpected input. Setting strict: 'throw' raises an error instead. strictQuery controls the same filtering for query filters, and since Mongoose 7 it defaults to false, so unknown filter fields are passed through to MongoDB rather than removed.

42. How do you prevent NoSQL injection in Mongoose queries?

Never pass raw user input as a query filter, because an object like { "$gt": "" } in a password or email field can change the query's meaning. Validate and cast input to expected types, use mongoose.sanitizeFilter(filter) or the sanitizeFilter option to wrap operator keys in $eq, and consider middleware such as express-mongo-sanitize. Also set a strict schema so unknown fields are ignored.

const user = await User.findOne(
  mongoose.sanitizeFilter({ email: req.body.email })
);

43. How do you manage multiple database connections in one Node.js app?

Use mongoose.createConnection(uri) to get an independent connection object and register models on it with conn.model('User', userSchema), instead of on the global mongoose instance. This is common for multi-tenant apps or when talking to separate databases. You can also reuse one connection with conn.useDb('tenant1') to switch databases cheaply while sharing the same connection pool.

44. How does connection pooling work in Mongoose, and how would you tune it?

Mongoose uses the MongoDB driver's connection pool, which keeps several sockets open and reuses them so each query does not pay connection setup cost. The default maxPoolSize is 100, and you can change it in the connect options. Set it based on your concurrency and the server's connection limits, especially in serverless setups where many instances multiply the connections.

await mongoose.connect(uri, { maxPoolSize: 20, serverSelectionTimeoutMS: 5000 });

45. How do you use Mongoose safely in a serverless environment such as AWS Lambda?

Cache the connection outside the handler so warm invocations reuse it instead of opening a new connection every time, and keep maxPoolSize small. Set bufferCommands appropriately and avoid calling connect() on every request. Many concurrent Lambda containers can still exhaust connection limits, so consider a pooled proxy or a managed service designed for this.

let conn = null;
exports.handler = async () => {
  conn ??= await mongoose.connect(process.env.MONGO_URI, { maxPoolSize: 5 });
  return User.find().lean();
};

46. What does Mongoose do with commands issued before the connection is ready (buffering)?

By default Mongoose buffers model operations until the connection is established, so a query issued right after connect() is queued rather than failing. If the connection never succeeds, the operation times out after bufferTimeoutMS, 10 seconds by default, with a buffering timeout error. You can disable it with bufferCommands: false to fail fast, which is useful when you want clear errors and control over startup order.

47. How do you use `bulkWrite()` and when is it better than many separate updates?

Model.bulkWrite() sends many insert, update and delete operations to MongoDB in a single round trip, which is far faster than awaiting each call in a loop. It is ideal for imports and syncing jobs. Be aware that it skips save middleware and by default does not run validators on updates, so validate the data yourself or pass the relevant options.

await Product.bulkWrite([
  { updateOne: { filter: { sku: 'A1' }, update: { $set: { price: 10 } } } },
  { deleteOne: { filter: { sku: 'B2' } } }
]);

48. How do you handle large result sets without running out of memory?

Avoid loading everything into an array with find(). Use a cursor with Model.find().cursor() and iterate with for await, so documents are fetched in batches and processed one at a time. Combine it with lean() and a projection to cut memory further. For transformations that can run on the server, an aggregation pipeline is often better than processing in Node.js.

for await (const doc of User.find().lean().cursor()) {
  await processUser(doc);
}

49. What are change streams and how can you use them with Mongoose?

Change streams let you subscribe to real-time changes on a collection, database or deployment, such as inserts, updates and deletes. In Mongoose you call Model.watch() and listen for change events, which is useful for cache invalidation, notifications and syncing to other systems. They require a replica set or sharded cluster, and you should handle errors and resume tokens so a restart does not lose events.

User.watch().on('change', (change) => {
  console.log(change.operationType, change.documentKey);
});

50. How would you structure and test a Mongoose-based application for production?

Keep schemas and models in separate files, put data-access logic in a service or repository layer instead of controllers, and use lean queries for read paths. Manage indexes through migrations with autoIndex off, validate environment config, and handle connection events. For tests, use mongodb-memory-server or a disposable test database, clear collections between tests, and test hooks and validators directly. Log slow queries to catch missing indexes early.

More Databases interview questions

MongoDB · DBMS and SQL · All interview questions