Schema migrations
Add a field or a virtual to a collection that already has documents, and list the documents that don't fit first.
Reads are not validated: a model returns what is stored, typed with the schema. When the schema changes, the documents written before may not fit it, and their types are wrong until you migrate them with an update.
Add a field with a default
A .default() fills the field on inserts and on the documents an upsert creates, not on the
documents already stored: a read returns what is stored, as Mongoose's lean() does, which skips
defaults (Mongoose docs). Reads type
tags as string[] from the start, so post.tags.length throws on an old post until it is
migrated. Deploy the schema, so that new posts have the field, and migrate the old ones:
import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string()
.min(1),
body: z.string(),
// New: posts written before don't have these.
tags: z.string()
.array()
.default([]),
excerpt: z.string()
.default(''),
createdAt: mz.date(),
updatedAt: mz.date()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
timestamps: true
})
const { modifiedCount } = await PostsModel.updateMany(
{ tags: { $exists: false } },
{ $set: { tags: [] } },
{ timestamps: false }
)The update is validated like any other. timestamps: false keeps the updatedAt of each post,
since the migration doesn't change what the post says.
When the new value depends on each document, use an update with a pipeline. Its values are expressions that the server computes, so the schema doesn't validate them:
await PostsModel.updateMany(
{ excerpt: { $exists: false } },
[{ $set: { excerpt: { $substrCP: ['$body', 0, 140] } } }],
{ updatePipeline: true, timestamps: false }
)List the documents that don't fit
Before you make the schema stricter, parse what is stored with safeParse and list what fails. Read
with the driver's collection, which returns the documents as stored:
for await (const post of PostsModel.collection.find()) {
const result = postSchema.safeParse(post)
if (!result.success) console.log(post._id.toHexString(), z.prettifyError(result.error))
}
// 6acd… ✖ Invalid input: expected string, received undefined
// → at bodyUse safeParseAsync when the schema has async refinements or transforms. Count each case with a
filter before deciding how to migrate it, such as { body: { $exists: false } }.
If the model applies a $jsonSchema,
jsonSchema: { validationLevel: 'moderate' } lets the server keep updating the documents that
don't pass it, so the migration can run. Switch to true, which is 'strict', once every document
fits.
Add a virtual
A virtual computes its value from fields of the document, with its own schema, on every read. If a
stored document doesn't pass that schema, the read throws a ZodError, so one old document breaks
every find that reaches it. Check what is stored against the virtual's schema before you deploy
it:
const userSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
firstName: z.string(),
// New: users written before only have `firstName`.
lastName: z.string()
})
const fullName = userSchema.pick({ firstName: true, lastName: true })
.transform((user) => `${user.firstName} ${user.lastName}`)
const UsersModel = mongozod({
collection: 'users',
db,
schema: userSchema,
virtuals: {
fullName
}
})
for await (const user of UsersModel.collection.find()) {
if (!fullName.safeParse(user).success) console.log(user._id)
}Then migrate those documents, or give the field a .default(): the virtual's schema applies it to
a document without the field, so lastName: z.string().default('') lets the old users through.
Common mistakes
- Reading the new field before migrating: the type says it's there, and it's
undefinedon every old document. Migrate them, or treat it as optional until you do. - Adding a virtual without checking the stored documents: one document that doesn't pass it
makes every read that reaches it throw. List them with
safeParsefirst. - Listing them with the model's
find: with the virtual already declared, it throws at the first invalid document. Read with.collection.find(), the driver's. - Migrating with
timestampson: every migrated document gets a newupdatedAt. Passtimestamps: false.
Last updated on
MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.