Fix: Mongoose Not Working — Connection Options Removed, strictQuery, populate, and Lean Queries
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.
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.
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.
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.