Skip to content

Fix: Mongoose Not Working — Connection Options Removed, strictQuery, populate, and Lean Queries

FixDevs ·

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 supported

Or 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 function

Why This Happens

Mongoose 7 and 8 made several breaking changes:

  • Connection options cleanup. useNewUrlParser, useUnifiedTopology, useCreateIndex, useFindAndModify are removed (their defaults are now permanent). Passing them throws.
  • strictQuery default flipped. Mongoose 6 set it to true. Mongoose 7 made it default false. Mongoose 8 changed again. The result: unknown fields in queries are sometimes silently dropped, sometimes throw.
  • populate is 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. The Document instance 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 pass tls=true in 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, populated

If populate("author") returns null but author field has a value:

  • The referenced doc was deleted (orphan reference).
  • The ref name 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 work

For 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 virtuals

When 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.id from _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 a models/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. Use markModified('path') or set the path via set('path', value).
  • TypeScript: Property X does not exist on type Document. You defined IUser but didn’t pass it to Schema<IUser>. Without the generic, Mongoose can’t infer types.
  • Aggregations don’t return Documents. $aggregate always returns plain objects, no Document methods. Either map after or use Model.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles