Back to Mongoose

Documents

docs/documents.md

9.7.014.0 KB
Original Source

Documents

Mongoose documents are Mongoose Document class instances backed by MongoDB data. Mongoose's Document class has built-in support for change tracking, casting, validation, middleware, and persistence.

<ul class="toc"> <li><a href="#what-is-a-document">What is a Document</a></li> <li><a href="#hydrated-documents-vs-lean-documents">Hydrated Documents vs Lean Documents</a></li> <li><a href="#updating-using-save">Updating Using <code>save()</code></a></li> <li><a href="#setting-nested-properties">Setting Nested Properties</a></li> <li><a href="#casting-and-validation">Casting and Validation</a></li> <li><a href="#required-properties">Required Properties</a></li> <li><a href="#middleware">Middleware</a></li> </ul>

What is a Document?

A Mongoose document is an instance of a Model class. Model and Document are separate classes in Mongoose: Model extends from Document.

javascript
// `User` is a Model class
const User = mongoose.model('User', new Schema({
  name: String
}));

// `doc` is a document
const doc = new User({ name: 'John Smith' });

// The class hierarchy is User extends from Model extends from Document
doc instanceof User; // true
doc instanceof mongoose.Model; // true
doc instanceof mongoose.Document; // true

Mongoose documents represent individual documents stored in a MongoDB collection. For example, documents created from the User model in the above example are stored in the users collection by default.

You can create a new document using new User() or await User.create(); or load an existing document from MongoDB using queries like findOne().

javascript
// Create a new document in memory
const doc = new User({ name: 'John Smith' });
// Persist the document to MongoDB:
// the document is not persisted to MongoDB until you call `save()`.
await doc.save();

// Load an existing document
const existingDoc = await User.findOne({ name: 'John Smith' });

existingDoc instanceof User; // true
existingDoc instanceof mongoose.Model; // true
existingDoc instanceof mongoose.Document; // true

Hydrated Documents vs Lean Documents

When you create a new document or load a document using a query, Mongoose returns a hydrated document. Mongoose documents are not plain-old JavaScript objects: they are an instance of Mongoose's Document class. In particular, Mongoose documents store internal state for:

  • change tracking
  • validation
  • middleware
  • document methods
  • save()
  • getters, setters, and virtuals

That means some JavaScript object operations behave differently with documents, most notably operations that depend on own enumerable properties or assignment semantics.

  • Using the delete operator will not unset the document property. For example, delete doc.name has no effect on the MongoDB document. To remove a property, use doc.name = undefined and then save() the document.
  • Using the spread operator on a Mongoose document does not create a shallow clone of the underlying object, you'll get an object with an _doc property instead.
  • Functions like Object.keys(), Object.values(), and Object.entries() inspect the document instance itself rather than the underlying document data. Use doc.toObject() first if you want to enumerate document properties. For example, Object.keys(doc.toObject()).
  • Using in on a document will always return true for properties that are in the Mongoose schema. For example, if userDoc is an instance of a User model with a name property, 'name' in userDoc will always evaluate to true. Use userDoc.name !== undefined to check for existence instead. Mongoose does not store properties in MongoDB that are === undefined.
  • Using nullish coalescing assignments ??= can be surprising if you are using them to set nested paths. For example, (doc.nested ??= {}).name = 'John Smith' will be a no-op because (doc.nested ??= {}) evaluates to a temporary object, and the subsequent .name assignment happens on that object rather than on the document path itself. Use doc.set('nested.name', 'John Smith') instead.

If you want a plain-old JavaScript object (POJO) representation of a Mongoose document, use the toObject() method.

javascript
const doc = await User.findOne();

// NOT RECOMMENDED
const copy = { ...doc };
copy.name; // undefined
copy; // { _doc: { name: 'John Smith' }, ... }

// To get a plain object clone of a document, use `toObject()`
const obj = doc.toObject();
obj.name; // 'John Smith'

You can use the lean() method to make Mongoose queries return POJOs instead of hydrated documents. Lean queries are often faster and use less memory, making them a good choice for read-only operations where you don't need document functionality.

javascript
const doc = await User.findOne().lean();

doc instanceof User; // false
doc.name; // 'John Smith'

However, keep in mind that lean() bypasses many Mongoose document features, including:

  • change tracking
  • validation
  • save()
  • getters
  • defaults
  • virtuals (including populated virtuals)

If you need some of these features on lean results, Mongoose provides helpers like Model.applyDefaults() and Model.applyVirtuals(), and there are plugins that can apply getters, virtuals, and defaults to lean query results. When using lean(), you are responsible for explicitly enabling any document features that your app needs.

Updating Using save() {#updating-using-save}

Mongoose documents have a save() method that persists the current document state to MongoDB. For new documents, save() inserts the document.

javascript
const doc = new User({ name: 'John Smith' });
// Inserts a new document
await doc.save();

For existing documents, save() sends an updateOne() that updates just the modified paths.

javascript
const doc = await User.findOne();

doc.name = 'Something else';
// Sends an updateOne with `{ $set: { name: 'Something else' } }` to MongoDB
await doc.save();

Mongoose documents track changes. When you assign to a document property, Mongoose marks that path as modified. The isModified() method lets you check whether a given path is modified, and the $getChanges() method returns the changes that will be sent to MongoDB when you call save().

javascript
doc.name = 'Something else';

doc.isModified('name'); // true
doc.$getChanges(); // { $set: { name: 'Something else' } }

In particular, this means save() only updates modified paths: it does not overwrite the entire document.

Setting Nested Properties

Mongoose documents have a set() function that you can use to safely set deeply nested properties.

javascript
const schema = new Schema({
  subdoc: new Schema({
    subdocLevel2: new Schema({
      name: String
    }, { _id: false })
  }, { _id: false })
});
const TestModel = mongoose.model('Test', schema);

const doc = new TestModel();
doc.set('subdoc.subdocLevel2.name', 'John Smith');
doc.subdoc.subdocLevel2.name; // 'John Smith'

Mongoose documents also have a get() function that lets you safely read deeply nested properties. get() lets you avoid having to explicitly check for nullish values, similar to JavaScript's optional chaining operator ?..

javascript
const doc2 = new TestModel();

doc2.get('subdoc.subdocLevel2.name'); // undefined
doc2.subdoc?.subdocLevel2?.name; // undefined

doc2.set('subdoc.subdocLevel2.name', 'Will Smith');
doc2.get('subdoc.subdocLevel2.name'); // 'Will Smith'

Casting and Validation

Before saving a document, Mongoose:

  • casts values to match the schema
  • validates the resulting values

Casting and validation are related, but they are different concepts and happen at different times.

Casting means converting values to the schema's configured type. Mongoose handles certain type conversions automatically, like converting the string '42' to a number for number paths or converting the number 0 to false for boolean paths.

javascript
const schema = new Schema({
  age: Number,
  isEnabled: Boolean
});
const Person = mongoose.model('Person', schema);

const doc = new Person();
doc.age = '42';
doc.isEnabled = 0;

doc.age; // 42 as a number
doc.isEnabled; // false

doc.isEnabled = 1;
doc.isEnabled; // true

If Mongoose cannot convert a value to the expected type, it creates a cast error and stores it on the document. Most importantly, assigning an invalid value does not throw immediately. Instead, Mongoose will report an error when you validate() the document (or call save(), which calls validate()).

javascript
// Does **not** throw
doc.age = 'not a number';

// Throws a ValidationError with a CastError for path `age`
await doc.validate();

This behavior allows Mongoose to collect multiple validation and casting errors together. Also, if you set a value multiple times, the last value wins, so if you overwrite an invalid value then validation will succeed.

javascript
doc.age = 'not a number';
doc.age = 42;

// Validation succeeds
await doc.validate();

Validation is a separate step that runs when you call the document's validate() method. The save() method calls validate() internally, so save() also triggers validation. Validation checks whether the document satisfies schema rules, which includes checking for cast errors, but also:

If validation fails, Mongoose does not save the document.

javascript
const schema = new Schema({
  age: {
    type: Number,
    min: 0
  }
});

const Person = mongoose.model('Person', schema);

const doc = new Person({ age: -1 });

// Throws an error "Path `age` (-1) is less than minimum allowed value (0)"
await doc.validate();

Required Properties

The most commonly used validator in Mongoose is required. If a required path is missing when you validate or save a document, Mongoose throws a validation error.

javascript
const schema = new Schema({
  name: {
    type: String,
    required: true
  }
});

const User = mongoose.model('User', schema);

const doc = new User();

// Throws an error "Path `name` is required"
await doc.validate();

For most schema types, any value that is not null or undefined will pass the required validator. The exceptions are strings and buffers: empty string and empty buffer cause a ValidationError if required is set.

javascript
const doc = new User({ name: '' });

// Throws an error "Path `name` is required"
await doc.validate();

Note that empty arrays do not cause a ValidationError if the array is required.

Middleware

Document middleware lets you run code during key parts of a document's lifecycle. The most common document middleware hooks are for validate() and save().

At a high level, saving a document looks like this:

For example, the following shows using a pre('validate') hook to set a normalizedName property.

javascript
const userSchema = new Schema({
  name: String,
  normalizedName: String
});

userSchema.pre('validate', function() {
  if (this.name != null) {
    this.normalizedName = this.name.trim().toLowerCase();
  }
});

userSchema.pre('save', function() {
  console.log('Saving user:', this.name);
});

const User = mongoose.model('User', userSchema);

const user = new User({ name: '  JOHN SMITH  ' });
await user.save();
user.normalizedName; // 'john smith'

Document middleware is a good fit for logic that is closely tied to the document itself, such as:

  • normalizing values before validation
  • deriving one path from another
  • enforcing document-level invariants
  • logging or auditing document saves
  • updating timestamps or metadata

However, use middleware sparingly. Middleware is usually not a good place for complex application logic. In particular, we recommend avoiding code in middleware that:

  • makes network calls to unrelated services
  • depends heavily on request-specific context
  • performs expensive work that should be explicit

Also note that document middleware only runs for document operations. Query updates like updateOne() and findOneAndUpdate() do not run save() middleware.

javascript
const doc = await User.findOne();

doc.name = 'Jane Doe';
// runs validate and save middleware
await doc.save();

// does not run save middleware
await doc.updateOne({ name: 'John Doe' });

Next Up

Now that we've covered Documents, let's take a look at Subdocuments.