For years, one of the longest-running frustrations we had with Mongoose wasn't performance, or casting, or even types. It was stack traces.
The stack traces had a habit of pointing everywhere except the actual line in user code that caused the problem. Debugging validation errors often felt like a murder mystery with no detective. With Mongoose 9, that is finally over.
This release is a big step toward a more modern, predictable, and debuggable Mongoose. Below are the 3 most important changes, starting with the one we're the most proud of.
1. True Async Stack Traces
We've been keeping a close eye on async stack traces since they were first introduced in 2018.
Async stack traces mean, as long as you're using async functions and await all the way through, Node.js will display the async function names in the stack trace instead of processTicksAndRejections.
For example, the following code will print a stack trace that includes the function names f1() and f2() in modern versions of Node.js.
async function f1() {
await f2();
}
async function f2() {
throw new Error('test error!');
}
f1();
In Mongoose 9, all internals now use async functions all the way down. The MongoDB Node driver has also converted to using async functions, which now means Mongoose will no longer lose the call site when an async error occurss.
To make real async stack traces work:
- All middleware must now be promise-based or async functions. Callback-based middleware is gone.
- Legacy
isAsyncmiddleware (function(next, done)) is gones. - Error handling prioritizes thrown errors, not
next()calls - because there are no morenext()calls.s
All of this lets Nodeβs async stack trace machinery finally work without interference.
The result? If your app throws inside middleware like:
// We recommend naming your async middleware functions - the function name will
// show up in the stack trace.
schema.pre('save', async function myPreSave() {
// π₯
throw new Error('Boom');
});
Your stack trace now actually shows the function that threw:
Error: Boom
at model.myPreSave (/src/models/user.js:42:11)For debugging production issues, this alone is worth the upgrade.
2. Middleware Is Now Fully Async β No Callbacks
Supporting async stack traces meant we had to remove every callback API from Mongoose.
The biggest culprit: pre() hooks.
In Mongoose 9, next() is no longer supported for pre() hooks.
// This worked in Mongoose 8, no longer supported in Mongoose 9!
schema.pre('save', function(next) {
// Do something async
next();
});
Similarly, done() is no longer supported for pre() hooks.
We introduced support for promise-based middleware in Mongoose 5, in Mongoose 9 that is the only supported pattern for async middleware.
// Using async functions
schema.pre('save', async function myPreSave() {
// Do something async
});
// Or, equivalently, using a function that returns a promise
schema.pre('save', function myPreSave() {
return new Promise((resolve, reject) => {
// Do something async
});
});
Dropping support for next() was a necessary step for full async stack traces.
Calling next() forces Node to break the async call chain, which is exactly what causes missing function names
3. Stricter TypeScript (and Better Autocomplete)
In Mongoose 8, query filters were typed as AnyObject.
Technically could be considered accurate, but isn't helpful for autocomplete or real world TypeScript usage.
For example, the following find() calls would pass TypeScript in Mongoose 8 (presuming age is a number), but throw TypeScript errors in Mongoose 9.
UserModel.find({ age: 'not a number' }); // β TS error
TestModel.find({ age: { $gt: 'not a number' } }); // β TS error
In Mongoose 8, the first parameter to find() and findOne() was called FilterQuery; in Mongoose 9 it's QueryFilter to reflect the major change and better align with modern MongoDB semantics.
Stricter query filters also means better autocomplete for both JavaScript and TypeScript developers. VSCode, Cursor, and Zed can now autocomplete:
- Top-level paths
- 1-level deep nested paths
- Type information for operators
- Common casting patterns, like strings for ObjectIds.
create() and insertMany() also get stricter types.
In Mongoose 8, you could pass any object to create() and TypeScript would allow it, which is impractical and prevents autocomplete.
In Mongoose 9, create() and insertMany() instead take a Partial<> of the schema paths, with some basic casting support.
// TypeScript allowed the following in Mongoose 8, errors out in Mongoose 9
TestModel.create({ age: 'not a number' }); // β TS error
TestModel.create({ notInSchema: 'test' }); // β TS error
TestModel.insertMany({ age: 'not a number' }); // β TS error
TestModel.insertMany({ notInSchema: 'test' }); // β TS error
Like queries, stricter create() and insertMany() types also means better autocomplete.
Moving On
If Mongoose 8 was the callback-to-async transition, Mongoose 9 is the async maturity release: the version where stack traces, middleware, error handling, and TypeScript all finally align with how developers write Node today.
While the new async stack traces and improved TypeScript support are the changes we're most proud of, there's 15 other major changes in Mongoose 9. Check out the full changelog for the complete list, or check out the migration guide for a more practical walkthrough.


