Mongoose 5.12.0 was released on March 11, 2021. This new release includes a few neat new features. In this article, I'd like to highlight a particularly useful new feature: the new transform option for populate(). This option lets you register a function that Mongoose will call on every populated document, which gives you more fine-grained control over what the results of populate() look like.

Initial Motivation: null if Not Found

The primary motivation for this new feature is the fact that, before Mongoose 5.12, populate() would always set values to null if the referenced document wasn't found.

In the below example, the child and children properties both point to an _id with no corresponding Child document. The populate() function replaces the _id with null.

const Child = mongoose.model('Child', mongoose.Schema({ name: String }));
const Parent = mongoose.model('Parent', mongoose.Schema({
  child: { type: 'ObjectId', ref: 'Child' },
  children: [{ type: 'ObjectId', ref: 'Child' }]

const validId = await Child.create({ name: 'Luke' }).then(doc => doc._id);
// No child with this `_id`
const invalidId = new mongoose.Types.ObjectId();

let doc = await Parent.create({
  child: invalidId,
  children: [invalidId, validId]

doc = await Parent.findById(doc).populate([
  // Mongoose will filter out `null` unless you set `retainNullValues`
  { path: 'children', options: { retainNullValues: true } }

doc.child; // null
doc.children; // [ null, { _id: 605b65ab78930836e101955a, name: 'Luke', __v: 0 } ]

The original motivation for the transform option was the ability to leave the unpopulated _id if no document was found, instead of explicitly setting the value to null. With that in mind, Mongoose calls the transform function with 2 arguments: the populated doc, and the original id that was used to populate this doc. Mongoose then uses the return value from the transform function as the populated doc. Here's how you can tell Mongoose to leave the populated path as an ObjectId if no document was found:

doc = await Parent.findById(doc).populate([
    path: 'child',
    transform: (doc, id) => doc == null ? id : doc 
    path: 'children',
    options: { retainNullValues: true },
    transform: (doc, id) => doc == null ? id : doc

// Mongoose uses the value the `transform()` function returned
doc.child; // 605b681bee716b3b8d83b51b
doc.children; // [ 605b681bee716b3b8d83b51b, { _id: 605b65ab78930836e101955a, name: 'Luke', __v: 0 } ]

Flattening Objects in Populate

The transform() function can return any value: it isn't limited to just documents. For example, suppose you want populate() to give you an array of strings, rather than an array of documents with a string property name. You can use transform() to return the child's name:

const children = await Child.create([{ name: 'Luke' }, { name: 'Leia' }]);

let doc = await Parent.create({ children });

doc = await Parent.findById(doc).populate([{
  path: 'children',
  transform: doc => doc == null ? null :

// `children` will be an array of strings rather than an array of documents
console.log(doc.children); // ["Luke","Leia"]

Moving On

The transform() option gives extra fine grained control over the results of populate(). It's just one of the 10 new features in Mongoose 5.12. There are several other neat features, including Query#pre() and Query#post(), and a getPopulatedDocs() method for documents that returns an array of all populated documents on a given document. You can find the full list on the Mongoose changelog. Make sure you upgrade to take advantage of all the new features!

Want to become your team's MongoDB expert? "Mastering Mongoose" distills 8 years of hard-earned lessons building Mongoose apps at scale into 153 pages. That means you can learn what you need to know to build production-ready full-stack apps with Node.js and MongoDB in a few days. Get your copy!

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus