Fix: Mongoose Not Working — Connection Options Removed, strictQuery, populate, and Lean Queries
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Mongoose errors — useNewUrlParser removed, strictQuery default flip, populate returning null, lean() losing methods, discriminator setup, transaction sessions, and TypeScript Document types.
The Error
You upgrade Mongoose 7+ and old code emits warnings:
DeprecationWarning: Mongoose: the `strictQuery` option will be switched back to `false`.Or the connection silently uses an old TLS setting:
MongooseError: option useNewUrlParser is not supportedOr populate returns null for a valid reference:
const post = await Post.findById(id).populate("author");
console.log(post.author); // null
// But the author document exists.Or lean() strips methods you needed:
const user = await User.findById(id).lean();
user.fullName(); // TypeError: user.fullName is not a functionWhy This Happens
Mongoose 7 and 8 made several breaking changes:
- Connection options cleanup.
useNewUrlParser,useUnifiedTopology,useCreateIndex,useFindAndModifyare removed (their defaults are now permanent). Passing them throws. strictQuerydefault flipped. Mongoose 6 set it totrue. Mongoose 7 made it defaultfalse. Mongoose 8 changed again. The result: unknown fields in queries are sometimes silently dropped, sometimes throw.populateis silent on misses. A reference pointing at a deleted doc returns null. Mongoose doesn’t error — your code may or may not handle the null.lean()returns plain objects. No virtuals, methods, getters. TheDocumentinstance is gone; only the raw BSON-mapped data remains.
The bigger picture is that Mongoose sits in an awkward spot between an ORM and a thin client. It maps documents to a typed schema, runs validation, exposes virtuals and middleware, and supplies a hydrated Document class with instance methods. That is great when you want OO-flavored access to your data, but every feature carries a cost — hydration allocates, virtuals run on every access, and middleware fires on every save. lean() exists because most production read paths do not need the hydration tax, and reaching for lean() aggressively is the single biggest performance lever in a mid-size Mongoose app.
The other quiet source of bugs is the divergence between schema state and query-time state. Schemas are defined once at module load, but options like strictQuery are read at query time from the Mongoose singleton. That means a setting changed later in the bootstrap can apply retroactively to schemas that were already registered, and tests that mutate global Mongoose state can leak into production code paths if not properly isolated. The fix is always the same: set global options at the top of your bootstrap, before importing any modules that define schemas, and never mutate them at runtime.
Fix 1: Modern Connection Setup
import mongoose from "mongoose";
await mongoose.connect("mongodb://localhost:27017/myapp", {
maxPoolSize: 10,
minPoolSize: 2,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
});Removed options (don’t pass these):
useNewUrlParser❌useUnifiedTopology❌useCreateIndex❌useFindAndModify❌keepAlive❌
Current options worth knowing:
maxPoolSize— connection pool size. Default is 100; usually too high. Set 10-20 for most apps.serverSelectionTimeoutMS— fail fast if server unreachable.socketTimeoutMS— timeout for individual queries.tls/tlsCAFile— TLS config (or passtls=truein URL).
For modern Atlas / production:
const url = process.env.MONGODB_URI!; // mongodb+srv://... includes most defaults
await mongoose.connect(url, {
maxPoolSize: 20,
serverSelectionTimeoutMS: 5000,
});
mongoose.connection.on("error", (err) => console.error("Mongo error:", err));
mongoose.connection.on("disconnected", () => console.warn("Mongo disconnected"));Pro Tip: Use mongodb+srv:// URLs for Atlas — they encode replica set discovery and TLS without options.
Fix 2: Set strictQuery Explicitly
Don’t rely on the default; set it explicitly per your needs:
import mongoose from "mongoose";
mongoose.set("strictQuery", true); // Throw on unknown fields in query
// or
mongoose.set("strictQuery", false); // Silently drop unknown fields
// or
mongoose.set("strictQuery", "throw"); // Same as `true`true is safer — typos in query fields surface as errors:
mongoose.set("strictQuery", true);
await User.find({ emai: "[email protected]" });
// StrictModeError: Path "emai" is not in schema and strictQuery is "throw"For schemas that need flexibility (allowing arbitrary query fields):
const flexibleSchema = new Schema({...}, { strictQuery: false });Per-schema overrides the global default.
Common Mistake: Setting strict mode after defining schemas. The setting is read at query time, so it works either order, but for code clarity, set it before defining schemas.
Fix 3: Populate Correctly
Basic populate:
const PostSchema = new Schema({
title: String,
author: { type: Schema.Types.ObjectId, ref: "User" },
});
const post = await Post.findById(id).populate("author");
console.log(post.author.name); // string, populatedIf populate("author") returns null but author field has a value:
- The referenced doc was deleted (orphan reference).
- The
refname doesn’t match the registered model name. - The user document is in a different database.
Debug:
const post = await Post.findById(id);
console.log(post.author); // ObjectId — confirms the reference exists
const author = await User.findById(post.author);
console.log(author); // null = deleted, doc = populate should workFor nested populate (populate a populated field):
const post = await Post.findById(id).populate({
path: "author",
populate: { path: "team" }, // Populate author.team
});For populating only certain fields:
.populate("author", "name email")
// Or:
.populate({ path: "author", select: "name email" })For matching on populated fields (find posts where author is active):
const posts = await Post.find({ "author.active": true })
.populate({
path: "author",
match: { active: true },
});
// Filters at the populate level; posts with non-matching authors get author: null.Common Mistake: Expecting populate to filter the parent. It populates after the find; non-matching populates produce null. To filter the parent, use $lookup aggregation or a two-step query.
Fix 4: lean() for Performance, Plain Objects
lean() returns plain JS objects, skipping Document instantiation:
const users = await User.find().lean();
// users: { _id, name, email }[] — no methods, no virtualsWhen to use lean():
- Reading data for an API response — you serialize anyway.
- High-volume reads where you don’t need Mongoose features.
When NOT to use lean():
- You need instance methods (
user.fullName(),user.comparePassword()). - You need virtuals (
user.idfrom_id). - You’ll modify and
.save()— Documents are required.
For lean + virtuals:
const users = await User.find().lean({ virtuals: true });
// virtual `id` (string) is included; methods still missing.For lean + getters:
const users = await User.find().lean({ getters: true });For type-safe lean queries:
type LeanUser = Omit<HydratedDocument<IUser>, keyof Document> & { _id: Types.ObjectId };
const users: LeanUser[] = await User.find().lean();Or use Mongoose’s LeanDocument type:
import { LeanDocument } from "mongoose";
const users: LeanDocument<IUser>[] = await User.find().lean();Fix 5: Discriminators for Schema Inheritance
For tables with mixed types (e.g. notifications with different shapes):
const baseOptions = { discriminatorKey: "kind", collection: "events" };
const EventSchema = new Schema({
timestamp: { type: Date, default: Date.now },
userId: { type: Schema.Types.ObjectId, ref: "User" },
}, baseOptions);
const Event = model("Event", EventSchema);
const ClickEvent = Event.discriminator("click", new Schema({
url: String,
element: String,
}));
const PurchaseEvent = Event.discriminator("purchase", new Schema({
amount: Number,
currency: String,
}));
// Insert different kinds:
await ClickEvent.create({ userId, url: "/", element: "header" });
await PurchaseEvent.create({ userId, amount: 9.99, currency: "USD" });
// Query both — auto-filtered by `kind`:
const clicks = await ClickEvent.find(); // Only kind: "click"
const purchases = await PurchaseEvent.find(); // Only kind: "purchase"
const all = await Event.find(); // All kinds in `events`All discriminators share the same MongoDB collection (events) but Mongoose filters by the kind field automatically.
Common Mistake: Querying Event.find({ kind: "click" }) instead of ClickEvent.find(). Both work, but the discriminator way gets the right schema validation and type checking.
Fix 6: Transactions With Sessions
For multi-document atomic operations:
const session = await mongoose.startSession();
session.startTransaction();
try {
await User.create([{ name: "Alice" }], { session });
await Account.create([{ userId: "...", balance: 0 }], { session });
await session.commitTransaction();
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}Or with the withTransaction helper:
const session = await mongoose.startSession();
await session.withTransaction(async () => {
await User.create([{ name: "Alice" }], { session });
await Account.create([{ userId: "...", balance: 0 }], { session });
});
session.endSession();withTransaction handles commit/abort/retry on transient errors.
Note: MongoDB transactions need a replica set. Single-node MongoDB doesn’t support them. For local dev:
docker run -d --name mongo \
-p 27017:27017 \
mongo:7 --replSet rs0
docker exec mongo mongosh --eval 'rs.initiate()'Fix 7: TypeScript Types
Define a model interface and let Mongoose infer types:
import { Schema, model, HydratedDocument, Model } from "mongoose";
interface IUser {
email: string;
name: string;
passwordHash: string;
createdAt: Date;
}
interface IUserMethods {
comparePassword(password: string): Promise<boolean>;
}
type UserModel = Model<IUser, {}, IUserMethods>;
const UserSchema = new Schema<IUser, UserModel, IUserMethods>({
email: { type: String, required: true, unique: true },
name: { type: String, required: true },
passwordHash: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
});
UserSchema.methods.comparePassword = async function (password: string) {
return await bcrypt.compare(password, this.passwordHash);
};
export const User = model<IUser, UserModel>("User", UserSchema);Now:
const user = await User.findOne({ email: "[email protected]" });
if (user) {
const ok = await user.comparePassword("password");
// user is HydratedDocument<IUser, IUserMethods>
}For lean() results:
const users = await User.find().lean();
// users: IUser[] (no methods)Pro Tip: Use Schema<IUser> (single generic) when you don’t have custom methods. The full Schema<IUser, UserModel, IUserMethods> form is only needed when you define methods or statics on the schema.
Fix 8: Connection Lifecycle in Production
For long-running servers (Express, NestJS):
// db.ts
import mongoose from "mongoose";
let connectionPromise: Promise<typeof mongoose> | null = null;
export function connect() {
if (!connectionPromise) {
connectionPromise = mongoose.connect(process.env.MONGODB_URI!, {
maxPoolSize: 20,
});
}
return connectionPromise;
}
export async function disconnect() {
await mongoose.disconnect();
connectionPromise = null;
}For serverless (Lambda, Vercel, Cloud Functions) — caching the connection across invocations:
let cached: { conn?: typeof mongoose; promise?: Promise<typeof mongoose> } = {};
export async function connect() {
if (cached.conn) return cached.conn;
if (!cached.promise) {
cached.promise = mongoose.connect(process.env.MONGODB_URI!);
}
cached.conn = await cached.promise;
return cached.conn;
}The global cached survives across invocations in warm containers — drops connection setup latency from ~500ms to 0.
Common Mistake: Calling mongoose.connect on every request in serverless. Each call opens a new pool, exhausting MongoDB’s connection limit fast.
Mongoose vs Prisma, TypeORM, Raw MongoDB Driver, and Typegoose
If you’re choosing how to talk to MongoDB from Node, Mongoose is the long-standing default but it’s no longer the only credible option.
Mongoose is the most opinionated. It gives you schemas, virtuals, middleware, populate, and a rich Document API. It pays for that with bundle size, runtime overhead from hydration, and a learning curve that’s larger than it looks. Use it when your team values explicit schemas, you lean on instance methods, and you accept the perf cost in exchange for fewer footguns.
Prisma’s MongoDB connector flips the model. You define the schema in schema.prisma, run prisma generate, and call a typed client. There is no Document class, no virtuals, no middleware (Prisma has its own extension API instead). Type safety is stronger because Prisma’s generated client knows the exact shape of every query result. The downsides on MongoDB are real: Prisma does not support every MongoDB operator, transactions across collections are limited, and changefeeds and aggregation pipelines need to fall through to the raw driver. Choose Prisma when type-safety on the read side matters more than the OO ergonomics of Mongoose.
TypeORM’s MongoDB support exists but it is a second-class citizen — the ORM was designed around SQL, and the MongoDB driver inherits SQL-shaped abstractions (entities, repositories, query builders) that don’t quite fit. It works, but every advanced MongoDB feature requires escaping the abstraction. Use TypeORM with MongoDB only when you already use TypeORM elsewhere and need a single ORM across both databases.
The raw MongoDB driver (mongodb package) is what every ODM eventually wraps. It is the smallest, fastest, and most expressive option. You manage your own types (or generate them from a separate source), validate input separately (Zod, Valibot, ArkType), and write aggregation pipelines as objects. Choose the raw driver when bundle size matters in serverless, when you use the driver’s full aggregation capability, or when an ODM keeps getting in your way.
Typegoose is a TypeScript wrapper on top of Mongoose. You write classes with decorators and Typegoose generates the Mongoose schema. The model is similar to TypeORM’s entity pattern but stays close to Mongoose under the hood. Use it when you want decorator-driven schemas without giving up Mongoose’s ecosystem.
Practical recommendation: keep Mongoose if you already use it and the perf is acceptable. Move to Prisma when you start a new project where type safety matters more than schema-level flexibility. Drop to the raw driver in performance-critical paths inside any of them.
Still Not Working?
A few less-obvious failures:
- **
MongooseError: Operation \users.find()` buffering timed out.** Your app started before the connection completed. Eitherawait connect()` before serving traffic, or use the lazy pattern in Fix 8. - Indexes not created. Mongoose builds indexes in the background on connection. For predictable behavior, call
await User.init()to wait for index creation before serving traffic. Schema hasn't been registered for model. You imported the model file but Mongoose still complains. The import order matters — make sure the schema is registered before any code references the model. Centralize in amodels/index.ts.Cast to ObjectId failed. A string isn’t a valid ObjectId. Validate inputs:mongoose.isValidObjectId(id)before querying.E11000 duplicate key error. Unique constraint violated. Either the data has a duplicate or the unique index was created with stale data. Drop and recreate the index.save()doesn’t update. A subdocument or array was mutated but Mongoose didn’t detect it. UsemarkModified('path')or set the path viaset('path', value).- TypeScript:
Property X does not exist on type Document. You definedIUserbut didn’t pass it toSchema<IUser>. Without the generic, Mongoose can’t infer types. - Aggregations don’t return Documents.
$aggregatealways returns plain objects, no Document methods. Either map after or useModel.hydrate(doc)to convert. findOneAndUpdatereturns the pre-update document. The default is the original document, not the updated one. Pass{ new: true }to get the post-update value, and{ runValidators: true }if you want schema validators to run on the update payload.- Hot reload duplicates schemas in dev. Next.js and Nest hot-reload re-evaluate model files; Mongoose throws
OverwriteModelError. Guard registration withmongoose.models.User ?? mongoose.model('User', schema). - Discriminator queries leak across collections. A discriminator child querying via
Model.find()filters bykind, but raw aggregation pipelines do not. Add$match: { kind: 'click' }explicitly in pipelines that target a single discriminator.
For related Node, MongoDB, and ORM issues, see MongoDB connect ECONNREFUSED, Mongoose validation failed, MongoDB duplicate key error, and MongoDB aggregation not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: MongoServerError: bad auth / MongoNetworkError: connect ECONNREFUSED / MongooseServerSelectionError
How to fix MongoDB 'MongoServerError: bad auth Authentication failed', 'MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017', and 'MongooseServerSelectionError' connection errors. Covers MongoDB not running, connection string format, Atlas network access, Docker networking, authentication, DNS/SRV issues, TLS/SSL, and Mongoose options.
Fix: Drizzle ORM Not Working — Schema Out of Sync, Relation Query Fails, or Migration Error
How to fix Drizzle ORM issues — schema definition, drizzle-kit push vs migrate, relation queries with, transactions, type inference, and common PostgreSQL/MySQL configuration problems.
Fix: Prisma Enum Not Working — Invalid Enum Value or Enum Not Recognized
How to fix Prisma enum errors — schema definition, database sync, TypeScript enum type mismatch, filtering by enum, and migrating existing enum values.
Fix: MongoDB Schema Validation Error — Document Failed Validation
How to fix MongoDB schema validation errors — $jsonSchema rules, required fields, type mismatches, enum constraints, bypassing validation for migrations, and Mongoose schema conflicts.