MongoZod

Migrating from Mongoose

Move a Mongoose app to MongoZod one model at a time, on the connection Mongoose already opened, with a prompt for your coding agent.

To migrate from Mongoose to MongoZod, move one model at a time. There is no big switch: MongoZod runs on the connection Mongoose already opened, so you ship each model on its own while the rest of the app keeps using Mongoose. This guide is for teams that want the Zod schema to be the single source of truth of each document.

A model on Mongoose:

import { Schema, model } from 'mongoose'

const postSchema = new Schema(
  {
    title: { type: String, required: true, trim: true },
    body: { type: String, default: '' },
    status: { type: String, enum: ['draft', 'published'], default: 'draft' },
    tags: [String],
    authorId: { type: Schema.Types.ObjectId, ref: 'User', required: true }
  },
  { timestamps: true }
)

postSchema.index({ authorId: 1, createdAt: -1 })

export const Post = model('Post', postSchema)

The same model on MongoZod, over the same collection and the same documents:

import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'

// What posts read from `users`, which Mongoose still owns.
const userSchema = z.object({

  _id: mz.objectId(),

  name: z.string()
})

const postSchema = z.object({

  _id: mz.objectId()
    .default(() => new ObjectId()),

  title: z.string()
    .trim()
    .min(1),

  body: z.string()
    .default(''),

  status: z.enum(['draft', 'published'])
    .default('draft'),

  tags: z.string()
    .array()
    .default([]),

  authorId: mz.objectId(),

  createdAt: mz.date(),

  updatedAt: mz.date(),

  __v: z.number()
    .int()
    .default(0)
})

const PostsModel = mongozod({

  collection: 'posts',

  db,

  schema: postSchema,

  indexes: (index) => [
    index({ authorId: 1, createdAt: -1 })
  ],

  references: {

    authorId: {

      collection: 'users',

      schema: userSchema
    }
  },

  timestamps: true
})

The schema keeps the names and shapes MongoDB already stores, so no document has to change. The cheatsheet maps each Mongoose feature to MongoZod, and Differences explains what works another way. Why MongoZod lists the Mongoose issues it solves.

The plan

  1. Install MongoZod next to Mongoose, with the same driver.
  2. Share Mongoose's connection.
  3. Migrate one model: its schema, its model and its call sites.
  4. Verify it, ship it, and go on with the next model.
  5. Remove Mongoose when no code uses it.

To hand a model to a coding agent, paste the prompt at the end of this page, or install it as a skill.

Install next to Mongoose

Both libraries have to use the same driver, so that a Db from Mongoose is the Db MongoZod expects. MongoZod takes mongodb ^6.20.0 || ^7.0.0: driver 7 is the one of Mongoose 9, and driver 6.20 the one of Mongoose 8.19 and later. Earlier versions of Mongoose 8 depend on an older driver, so update Mongoose first.

Find the driver Mongoose installed, then install that same version, with bson of its major:

npm ls mongodb
# └─┬ mongoose@9.11.1
#   └── mongodb@7.7.0

npm install mongozod zod mongodb@7.7.0 bson@7

With Mongoose 8.19 or later, the driver is a 6.x, such as mongodb@6.20.0, with bson@6. Run npm ls mongodb again: it should show one version, deduped under mongoose. See Installation for the rest of the requirements.

Share the connection

Mongoose's connection wraps the driver's: once it is open, mongoose.connection.db is the driver's Db (Mongoose docs). Give MongoZod a function that returns it:

import mongoose from 'mongoose'

/** The database of Mongoose's default connection, for the mongozod models. */
export function mongooseDb() {

  const { db } = mongoose.connection

  if (!db) throw new Error('Mongoose is not connected yet')

  return db
}

and pass it as db, in place of the db of the examples:

const PostsModel = mongozod({

  collection: 'posts',

  db: mongooseDb,

  schema: postSchema
})

A model calls the function on every operation (see Models), so the modules that define models can load before mongoose.connect() resolves. Until then the function throws, and so does any operation. It returns the same Db on every call, unlike mongoose.connection.getClient().db(), which builds a new one each time.

Both libraries now share the connection pool, and a session started by Mongoose is the driver's, so it works in the operations of both:

await mongoose.connection.transaction(async (session) => {
  await Post.create([{ title: 'Hello, MongoDB', authorId }], { session })
  await CommentsModel.insertOne({ postId, body: 'First!' }, { session })
})

Pass { session } to each MongoZod operation, also when Mongoose's transactionAsyncLocalStorage is on: that option sets the session of Mongoose's operations only (Mongoose docs). See Transactions.

Migrate one model

Pick a model, migrate it whole, and ship it before the next one. Its Mongoose model can stay defined until no code uses it.

1. Write the Zod schema

Translate the Mongoose schema with the cheatsheet. The schema describes the document as MongoDB stores it, so keep what is there:

  • The collection: Mongoose names it after the model, lowercased and in plural, unless the schema sets collection (Mongoose docs): Post stores in posts. MongoZod takes the name as it is.
  • Field names and shapes: rename nothing while you migrate. A rename is a separate change that migrates the documents too.
  • What is optional: a Mongoose path is optional unless it says required, while a Zod key is required unless it says .optional(). Mongoose arrays default to [] (Mongoose docs), so they take .default([]).
  • The _ids: declare the _id with mz.objectId().default(() => new ObjectId()), and the same in each subdocument, which Mongoose gives an _id (Mongoose docs). A replacement drops the keys a z.object does not declare. See IDs.
  • __v: declare it as z.number().int().default(0) while Mongoose still writes the collection, unless its schema sets versionKey: false (see __v).
  • timestamps: declare createdAt and updatedAt with mz.date() and pass timestamps: true. Both libraries store them with the same names and types (see Timestamps).

Without a Mongoose schema, such as for a collection another service writes or a field of type Mixed, start from the $jsonSchema of the stored documents. MongoDB Compass exports it from the Schema tab: Analyze Schema, then Export Schema in the MongoDB format (Compass docs). From code, @mongodb-js/mongodb-schema does the same:

import { analyzeDocuments } from '@mongodb-js/mongodb-schema'

const sample = db.collection('posts').aggregate([{ $sample: { size: 1000 } }])
const jsonSchema = await (await analyzeDocuments(sample)).getMongoDBJsonSchema()

Both read a sample: a field that every sampled document has comes out as required, and the result has no defaults, no enums and no references. Decide those from the code that writes the collection.

2. Define the model

Pass the options that replace the rest of the Mongoose schema: indexes, references, virtuals, timestamps and plugins, each in the cheatsheet. A reference to a collection that Mongoose still owns points to the collection and a schema of what you read from it, as authorId does above; once that model is migrated too, point it to the model: authorId: () => UsersModel.

Middleware moves to .transform()s of the schema or to the hooks of plugins, as the cheatsheet maps it.

3. Update the call sites

Find every use of the Mongoose model and rewrite it with the cheatsheet. TypeScript guides you: most Mongoose habits do not compile, such as a string _id in a filter, an update without operators, or a sort, a projection or a populate in a string. Before:

const post = await Post.findById(id).select('title status').lean()

await Post.findByIdAndUpdate(id, { status: 'published' }, { new: true })

const drafts = await Post.find({ authorId, status: 'draft' })
  .sort('-createdAt')
  .limit(20)
  .populate('authorId', 'name')

After:

// The ids of the route, such as req.params.id.
const id = mz.objectId().parse('64f1c2a9e4b0a1b2c3d4e5f6')
const authorId = mz.objectId().parse('64f1c2a9e4b0a1b2c3d4e5f7')

const post = await PostsModel.findOne({ _id: id }, { projection: { title: 1, status: 1 } })

await PostsModel.findOneAndUpdate(
  { _id: id },
  { $set: { status: 'published' } },
  { returnDocument: 'after' }
)

const drafts = await PostsModel.find(
  { authorId, status: 'draft' },
  { sort: { createdAt: -1 }, limit: 20, populate: { authorId: { projection: { name: 1 } } } }
)

Code that changed a document and called save() writes the change with an operator instead (see Differences).

Verify

Before shipping the model:

  • Types: tsc passes with no new as casts or any. A cast hides the errors that point at Mongoose habits.

  • Tests: run them against MongoDB, such as with mongodb-memory-server, so that the server checks what the types cannot.

  • Stored documents: MongoZod validates writes, not reads, so a document stored before the schema existed is read as it is, whatever its type says. Check them all once, through the driver's collection, which returns them as stored: the model's find computes the virtuals, which throw on a document that does not fit.

    const invalid: Array<{ _id: ObjectId; issues: string }> = []
    
    for await (const stored of PostsModel.collection.find()) {
      const result = postSchema.safeParse(stored)
    
      if (!result.success) invalid.push({ _id: stored._id, issues: z.prettifyError(result.error) })
    }

    Use safeParseAsync when the schema has async refinements. Fix the schema where it is wrong, and the documents where they are (see Schema migrations).

  • Indexes: run sync() against a development database. It creates the declared indexes that are missing, and returns in extraIndexes the ones the collection has and the model does not declare, such as one you forgot to translate. It never drops an index (see Sync).

    const { extraIndexes } = await PostsModel.sync()

Remove Mongoose

Delete a Mongoose model once no code calls it and no Mongoose schema has a ref to it: Mongoose populates a ref with the model of that name (Mongoose docs).

When the last model is gone, open the connection with the driver, give MongoZod its Db, and remove Mongoose:

import { MongoClient } from 'mongodb'

export const client = new MongoClient('mongodb://localhost:27017')
export const db = client.db()

Without Mongoose, nothing creates the indexes when the app starts: run sync() in a deploy step (see When to run it), or add the autoIndex plugin to the models.

Then you can drop __v from the schemas (see __v), and turn on jsonSchema in the models, so that the server checks every write too (see Sync). While Mongoose still writes a collection, the server would reject its writes that do not fit the schema.

Copy prompt

Paste this into your coding agent to migrate one model. Fill in the model and its files first.

Migrate the Mongoose model <Model> (<path to its file>) to mongozod, without changing the data
that MongoDB stores. Do this model only.

Read first, from the mongozod docs installed with the package, in node_modules/mongozod/dist/docs/:
README.md, mongoose/index.md, mongoose/cheatsheet.md and mongoose/differences.md, then
guides/schemas.md, guides/models.md, guides/operations.md, guides/validation.md and
guides/security.md, and the guide of each feature the model uses (populate, virtuals, timestamps,
document-types, sync, plugins).
They match the installed version: follow them over what you remember of mongozod or Mongoose.

1. Write the Zod schema of the stored document, with mongoose/cheatsheet.md:
   - Keep every field name, shape and the collection name as stored. Mongoose names the
     collection after the model, lowercased and in plural, unless the schema sets `collection`.
   - A Mongoose path without `required` is `.optional()`. An array is `.default([])`. A required
     String is `.min(1)`.
   - Declare the `_id` as `mz.objectId().default(() => new ObjectId())`, and the same in each
     subdocument, unless it has `_id: false`.
   - Declare `__v: z.number().int().default(0)` while Mongoose still writes the collection,
     unless the Mongoose schema sets `versionKey: false`.
   - With `timestamps`, declare `createdAt` and `updatedAt` as `mz.date()`.
   If the collection has no Mongoose schema, start from its $jsonSchema: <path to the file
   exported from MongoDB Compass or @mongodb-js/mongodb-schema>. It comes from a sample, so
   decide from the code that writes the collection which fields are required, which values an
   enum takes, the defaults and which fields store the _id of another document.

2. Define the model with mongozod() on Mongoose's connection, with `db: mongooseDb` from
   <path to the module of mongooseDb, or create it as mongoose/index.md shows>. Translate the
   indexes, refs, virtuals, timestamps and plugins with the cheatsheet. A ref to a collection
   that is still on Mongoose is a reference to `{ collection, schema }`. A pre('save') that changes
   a field is a .transform() of that field; other middleware is a hook of a plugin.

3. Find every call site of <Model> and rewrite it with the cheatsheet and mongoose/differences.md:
   explicit operations instead of documents and save(), updates with operators, ObjectIds instead
   of strings in filters (parse them with mz.objectId() or take them through mz.filter), and the
   projection, sort and populate as objects in the options. Keep the Mongoose model if other code
   still uses it or another Mongoose schema has a ref to it.

4. Keep the security of each call site, with guides/security.md. mongozod doesn't sanitize or
   cast filters at runtime, and a request body is typed `any`, so tsc doesn't catch it:
   - Parse the input of each endpoint once, before the model: .pick() of the model's schema for
     values, the mz builders (mz.filter, mz.update, mz.sort, mz.projection, mz.populate, mz.after,
     mz.term…) for MongoDB syntax from the client, with mz.from(schema) to name the schema once,
     and mz.objectId() for an id. Check whether the project used sanitizeFilter,
     strictQuery or mongoose.trusted().
   - Never pass req.body or req.query whole as a filter, an update or a document to insert.
   - Keep every owner or tenant condition, joined with the client's filter with $and, never with
     a spread.
   - A query that becomes an aggregate skips the plugins, such as soft delete: keep their
     conditions in its $match. Exclude each select: false field in every read of the model.

5. Check, and fix what fails:
   - tsc passes, with no `as` casts, `any`, @ts-ignore or @ts-expect-error added to make it
     compile.
   - The tests pass against MongoDB.
   - Write a script, which I will run, that reads every stored document through the model's
     `collection`, as mongoose/index.md shows, and reports the ones that fail `schema.safeParse()`.
   - Run sync() only against a development database, and report its extraIndexes.

6. Report the files you changed, every decision the Mongoose schema left open, what mongozod
   does not have yet that the model used, as listed in mongoose/differences.md, and each call
   site that takes data from a request, with how its input is parsed now.

Common mistakes

  • Two copies of the driver: a mongodb version other than Mongoose's installs a second copy, and passing Mongoose's Db to MongoZod fails with TS2322, Types have separate declarations of a private property. Install the version npm ls mongodb shows under mongoose.
  • Reading mongoose.connection.db when a module loads: it is undefined until the connection opens. Pass the function, db: mongooseDb, not its result.
  • Relying on transactionAsyncLocalStorage: a MongoZod operation without { session } runs outside the transaction, and an abort does not undo it. Pass the session to each one.
  • Another collection name: collection: 'post' for a model named Post reads an empty collection, without an error. Check the name Mongoose used, posts here.
  • Leaving __v or the _id of subdocuments out of the schema: a replaceOne or findOneAndReplace removes from the stored document what the schema does not declare. Declare both while Mongoose shares the collection.
  • Turning on jsonSchema while Mongoose writes the collection: the server applies the validator to every write, so Mongoose's writes that do not fit the schema fail. Turn it on once MongoZod is the only writer (see Sync).

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.

On this page