← All posts
·13 min read

Claude Code with Mongoose: MongoDB ODM Without the Footguns

Claude CodeMongooseMongoDBNode.js

Why Mongoose without CLAUDE.md produces slow queries

Mongoose is the most-used MongoDB ODM for Node.js, and the framework Claude Code is most likely to generate code for when you mention MongoDB. The framework has been around long enough to accumulate sharp edges: a permissive schema model where any field can be added to any document by default, an populate() API that performs N+1 queries unless you understand its cost, a query API that returns hydrated documents (slow) instead of plain objects (fast) by default, and connection patterns that Claude gets wrong because the quickstart docs use a different pattern than production code.

The most common Mongoose mistakes Claude makes by default: creating a new connection per request, defining schemas without indexes and discovering the missing indexes only when the collection grows past 10k documents, calling .populate() inside a loop and producing N+1 queries, returning full Mongoose documents to API clients (with validators, virtuals, and methods all attached), skipping transactions for multi-document writes that need atomicity, and using strict: false schemas because Claude saw it in an old Stack Overflow answer.

This guide covers the CLAUDE.md configuration that locks Claude Code into Mongoose's production model: one connection per process, schemas with indexes declared inline, populate or aggregate decisions made explicitly, lean queries for read paths, transactions for multi-write operations, and TypeScript types generated from the schema. If you are building the broader Node.js stack around Mongoose, Claude Code with Express and Claude Code with NestJS cover the request lifecycle that Mongoose models sit inside.

The Mongoose CLAUDE.md template

The CLAUDE.md at your project root needs to declare which Mongoose version you are using, the connection pattern, the schema conventions, and the query patterns that prevent the most common performance problems.

# Mongoose rules

## Stack
- mongoose ^8.x, TypeScript 5.x strict
- Node.js 20.x
- MongoDB 7.x (replica set required for transactions, even single-node)
- MONGODB_URI in .env (full connection string with replicaSet=rs0 for single-node)

## Project structure
- src/db/connect.ts        Connection singleton
- src/models/              One file per model, exporting model + types
- src/db/transactions.ts   Transaction helpers
- src/db/indexes.ts        One-shot index creation script (run on deploy)

## Connection (ONE per process)
- src/db/connect.ts owns the only mongoose.connect() call
- All routes/handlers import the connect function and call it once at boot
- NEVER call mongoose.connect() in a route handler
- NEVER create multiple connections via mongoose.createConnection() unless multi-DB

## Schema conventions
- strict: 'throw' (NOT the default 'warn', NOT 'false')
- timestamps: true on every schema (adds createdAt, updatedAt)
- versionKey: '__v' kept on (used for optimistic concurrency)
- toJSON: { virtuals: true, transform: removeMongoInternals }
- Declare ALL indexes on the schema, not in MongoDB Compass

## Query patterns
- Read-only queries: ALWAYS use .lean() (returns plain objects, 5x faster)
- ALWAYS specify .select() for fields you need, never fetch full documents in lists
- NEVER use .populate() inside a loop (N+1), use aggregation or batch fetch
- NEVER use .find() in loops, use .find({ _id: { $in: ids } })

## Hard rules
- NEVER use strict: false (allows any field to be written, breaks types)
- NEVER skip indexes on fields used in queries, sorts, or joins
- NEVER return raw Mongoose documents to API clients, use lean or toJSON transform
- NEVER use multi-document writes without a transaction if atomicity matters
- ALWAYS define types with InferSchemaType<typeof schema>, never duplicate

Three rules in this template prevent the majority of failures Claude generates without them.

The connection singleton rule matters most for production stability. MongoDB drivers maintain a connection pool, and creating multiple Mongoose connections produces multiple pools that fight for the same MongoDB connection limit. Claude generated code that does await mongoose.connect(uri) inside a route handler will succeed on local dev (where the same process handles every request) and fail in production when connection limits are exhausted under load.

The strict 'throw' rule is the difference between catching bugs at write time and finding them at read time, weeks later. With strict: 'warn' (the default in Mongoose 8), writing an undefined field logs a warning and silently drops the field. With strict: 'throw', the write throws an error and surfaces the typo. The performance cost is zero. The correctness benefit is large.

The lean() rule is the single biggest performance win for read paths. Mongoose's default behaviour returns hydrated documents with all the model's methods, virtuals, and getters attached. For a list endpoint returning 100 documents, that overhead is significant. .lean() returns plain JavaScript objects with no Mongoose wrapping, typically 5x faster.

Install and connection setup

Install Mongoose:

npm i mongoose

Set up the connection singleton:

// src/db/connect.ts
import mongoose from 'mongoose';

if (!process.env.MONGODB_URI) {
  throw new Error('MONGODB_URI is not defined');
}

let connected = false;
let connecting: Promise<void> | null = null;

export async function connectMongo(): Promise<void> {
  if (connected) return;
  if (connecting) return connecting;

  connecting = mongoose
    .connect(process.env.MONGODB_URI!, {
      maxPoolSize: 10,
      minPoolSize: 2,
      serverSelectionTimeoutMS: 5000,
      socketTimeoutMS: 45000,
      retryWrites: true,
    })
    .then(() => {
      connected = true;
      console.log('[mongo] connected');
    });

  return connecting;
}

mongoose.connection.on('disconnected', () => {
  connected = false;
  console.warn('[mongo] disconnected');
});

mongoose.connection.on('reconnected', () => {
  connected = true;
  console.log('[mongo] reconnected');
});

The promise tracking matters. Without the connecting promise, two concurrent calls to connectMongo() at startup will both call mongoose.connect(), racing to set the singleton state. Claude often writes the simpler version without the in-flight promise, which works on local dev with one warm-up request and fails when production workers all wake up simultaneously.

The maxPoolSize: 10 is the default but worth declaring explicitly because Claude will sometimes set it to 100, thinking more is better. MongoDB has connection limits (configurable, but typically 100 to 500 per replica set), and each Mongoose process opens up to maxPoolSize connections. Setting the limit too high on multiple workers exhausts the database limit.

Add a connection section to CLAUDE.md:

## Connection

- ONE mongoose.connect() per process, called from src/db/connect.ts
- Track in-flight connection with a promise to handle concurrent connect calls
- maxPoolSize: 10 per process (do NOT increase without measuring DB load)
- serverSelectionTimeoutMS: 5000 (5 seconds before failing)
- socketTimeoutMS: 45000 (45 seconds before idle socket close)
- Listen for 'disconnected' and 'reconnected' events for observability
- NEVER mongoose.disconnect() in shutdown handlers unless graceful drain is implemented

Schema definitions

Schemas in Mongoose are the contract between TypeScript and MongoDB. Done correctly, they double as type definitions, validation rules, and index declarations. Done incorrectly, they are a permissive bag of fields that drift from the application's actual data model.

// src/models/User.ts
import { Schema, model, InferSchemaType, Model } from 'mongoose';

const userSchema = new Schema(
  {
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
      match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    },
    name: {
      type: String,
      required: true,
      trim: true,
      minlength: 1,
      maxlength: 100,
    },
    role: {
      type: String,
      enum: ['admin', 'member', 'viewer'],
      default: 'member',
      required: true,
    },
    organisationId: {
      type: Schema.Types.ObjectId,
      ref: 'Organisation',
      required: true,
    },
    lastSeenAt: {
      type: Date,
      default: null,
    },
  },
  {
    timestamps: true,
    strict: 'throw',
    toJSON: {
      virtuals: true,
      transform: (_doc, ret) => {
        ret.id = ret._id;
        delete ret._id;
        delete ret.__v;
        return ret;
      },
    },
  }
);

userSchema.index({ organisationId: 1, role: 1 });
userSchema.index({ lastSeenAt: -1 });

export type UserDoc = InferSchemaType<typeof userSchema>;
export const User: Model<UserDoc> = model<UserDoc>('User', userSchema);

A few details worth pulling out.

InferSchemaType generates the TypeScript type from the schema definition. This single source of truth means the type cannot drift from the actual schema. Claude often defines a separate interface IUser { ... } and a separate Mongoose schema, which inevitably go out of sync. Using InferSchemaType keeps them aligned.

The toJSON transform converts _id to id and strips the version key. This is the response shape your API clients want, and defining it on the schema means every serialisation produces consistent output. Claude often writes this transformation logic in every controller, which is duplicative and breaks when a new endpoint forgets to apply it.

The two indexes ({ organisationId: 1, role: 1 } and { lastSeenAt: -1 }) are declared in the schema file, next to the field definitions they support. This co-locates the index with the access pattern it serves. Claude tends to add indexes ad hoc in MongoDB Compass when a query is slow in production, which means indexes get lost when you rebuild the database.

Add a schema section to CLAUDE.md:

## Schemas

- One schema per file, in src/models/
- Use InferSchemaType<typeof schema> for the TypeScript type (NEVER duplicate)
- strict: 'throw' (not 'warn', not false)
- timestamps: true (every schema, no exceptions)
- toJSON: convert _id to id, strip __v, this is the API response shape
- Declare indexes on the schema file, not in MongoDB Compass
- ObjectId references use Schema.Types.ObjectId with ref: 'ModelName'
- Use match for regex validators on string fields where format matters

Query patterns: lean and select

The default Mongoose query returns a hydrated document with all model methods, virtuals, and getters attached. For a single-document fetch this is fine. For list endpoints returning many documents, the overhead is substantial.

// Slow: returns hydrated Mongoose documents
const users = await User.find({ organisationId: orgId });

// Fast: returns plain JavaScript objects
const users = await User.find({ organisationId: orgId }).lean();

The performance difference scales with document count and document size. A list of 100 documents with .lean() is typically 5 to 10 times faster to serialise to JSON than the hydrated version. For an API endpoint that returns lists, .lean() should be the default.

.select() narrows the fields returned from the database, which reduces both network transfer and memory:

// Returns all fields
const users = await User.find({ organisationId: orgId }).lean();

// Returns only the fields needed for a list view
const users = await User.find({ organisationId: orgId })
  .select('email name role lastSeenAt')
  .lean();

Add a query patterns section to CLAUDE.md:

## Query patterns

- find() for lists: ALWAYS .lean() unless calling instance methods
- findOne() for single: .lean() if read-only, hydrated if .save() will follow
- ALWAYS .select() the fields you need, never fetch full documents in lists
- For typed lean results: User.find().lean<UserDoc[]>()
- Pagination: .skip() and .limit() are O(N), use cursor-based for large collections
- .exec() at the end forces full promise (not strictly required, but explicit)

populate vs aggregation

populate() is the API Claude reaches for to fetch referenced documents. It is also the API that produces N+1 queries when used inside a loop. Understanding when to use populate() and when to use the aggregation framework is the biggest performance lever in Mongoose code.

// N+1 pattern: avoid
const orgs = await Organisation.find().lean();
for (const org of orgs) {
  org.users = await User.find({ organisationId: org._id }).lean();
}

Each iteration fires a separate query. For 100 organisations, that is 101 queries. Replace with aggregation:

const orgsWithUsers = await Organisation.aggregate([
  {
    $lookup: {
      from: 'users',
      localField: '_id',
      foreignField: 'organisationId',
      as: 'users',
    },
  },
]);

The aggregation produces the same nested result in one round trip to MongoDB. For two levels of joins, aggregation scales while populate() chains do not.

The populate() API is correct for single-document fetches:

// Fine: one query for the user, one for the related organisation
const user = await User.findById(userId).populate('organisationId').lean();

Get Claudify. The Mongoose-aware CLAUDE.md template lands in your repo with connection patterns, schema conventions, and query rules pre-configured.

Add a populate vs aggregate section to CLAUDE.md:

## populate vs aggregate

- populate() is fine for ONE document with related references
- populate() is wrong inside loops (N+1)
- populate() chains (.populate('a').populate('b.c')) become inefficient past 2 levels
- aggregate() with $lookup is right for lists with references
- aggregate() returns plain objects (no hydration needed), inherently faster
- For complex queries: aggregate() with multiple stages
- ALWAYS profile populate-heavy queries before deploying to production

Transactions

Multi-document writes that need atomicity require transactions. MongoDB requires a replica set to support transactions, but single-node replica sets are valid (run mongod --replSet rs0 for development).

// src/db/transactions.ts
import mongoose from 'mongoose';

export async function withTransaction<T>(
  fn: (session: mongoose.ClientSession) => Promise<T>
): Promise<T> {
  const session = await mongoose.startSession();
  let result: T;

  try {
    await session.withTransaction(async () => {
      result = await fn(session);
    });
    return result!;
  } finally {
    await session.endSession();
  }
}

Using it:

import { withTransaction } from '@/db/transactions';
import { User } from '@/models/User';
import { Organisation } from '@/models/Organisation';

export async function createOrgWithOwner(
  orgName: string,
  ownerEmail: string,
  ownerName: string
) {
  return withTransaction(async (session) => {
    const [org] = await Organisation.create(
      [{ name: orgName, plan: 'free' }],
      { session }
    );

    const [user] = await User.create(
      [
        {
          email: ownerEmail,
          name: ownerName,
          role: 'admin',
          organisationId: org._id,
        },
      ],
      { session }
    );

    return { org, user };
  });
}

The two patterns Claude misses without guidance.

First, Model.create([...], { session }) requires an array even when creating a single document, because the session option is only honoured on the array form. Claude often writes await User.create({ ... }, { session }) which silently drops the session and runs outside the transaction.

Second, every operation inside the transaction must accept the session. Forget to pass { session } to a single findOneAndUpdate and that operation runs outside the transaction boundary. There is no compile-time check for this. The CLAUDE.md rule should be explicit.

## Transactions

- ALWAYS use the withTransaction helper, not raw startSession + commitTransaction
- ALL operations inside the transaction MUST pass { session }
- Model.create with session: ALWAYS use array form [doc], even for single docs
- Single-node MongoDB needs --replSet rs0 to support transactions
- Transactions have a 60 second default time limit, do not put slow operations inside
- Read your own writes pattern: reads inside the transaction see the uncommitted writes

Common Claude Code mistakes with Mongoose

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Connection per request

Claude generates: await mongoose.connect(process.env.MONGODB_URI) inside a route handler.

Correct pattern: One singleton connection in src/db/connect.ts, called once at boot.

2. Default strict mode

Claude generates: schemas without an explicit strict setting, getting the default 'warn' that silently drops unknown fields.

Correct pattern: strict: 'throw' on every schema.

3. Duplicated interface and schema

Claude generates: interface IUser { ... } declared separately from the Mongoose schema, with the two going out of sync over time.

Correct pattern: type UserDoc = InferSchemaType<typeof userSchema>.

4. No lean() on list queries

Claude generates: await User.find({ ... }) for endpoints that return 50 to 500 user records.

Correct pattern: await User.find({ ... }).select('id email name').lean().

5. populate() in a loop

Claude generates: for (const org of orgs) { org.users = await User.find({ organisationId: org._id }) }.

Correct pattern: aggregation pipeline with $lookup.

6. Multi-write without transaction

Claude generates: await Organisation.create({ ... }); await User.create({ ... }) for operations that need atomicity.

Correct pattern: wrap both in withTransaction((session) => { ... }).

Add a common mistakes section to CLAUDE.md with these six pairs. Claude reproduces patterns from CLAUDE.md examples more reliably than from abstract rules.

Permission hooks for Mongoose scripts

A Mongoose project accumulates scripts: seeders, migrations, index builders, backfills. Some are safe, some can destroy production data.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(npm run dev*)",
      "Bash(node scripts/list-users.js*)",
      "Bash(node scripts/check-indexes.js*)",
      "Bash(node scripts/preview-migration.js*)"
    ],
    "deny": [
      "Bash(node scripts/drop-collection.js*)",
      "Bash(node scripts/migrate-prod.js*)",
      "Bash(node scripts/backfill-emails.js*)",
      "Bash(mongosh*--eval*drop*)"
    ]
  }
}

Dropping collections, running migrations against production, and backfills all have side effects that should require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them inline. For broader patterns on testing migrations safely, Claude Code with Prisma covers the same protective pattern in a different ORM model.

Building MongoDB code that scales

The Mongoose CLAUDE.md in this guide produces MongoDB integrations where the connection is a singleton, schemas are the source of truth for types and indexes, list queries use lean and select, populate is used only where appropriate, multi-document writes use transactions, and types are inferred from schemas rather than duplicated.

The underlying principle is the same as any framework integration with Claude Code. Mongoose without a CLAUDE.md produces code that runs and scales to 100 documents but fails to scale to 100,000: missing indexes, populate loops, hydrated lists, and silent schema drift compound into queries that take seconds where they should take milliseconds. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude generates.

For the next layer of the stack, Mongoose works alongside Claude Code with Express for the API layer, and Claude Code with Sentry for catching the slow queries before they hit production.

Get Claudify. Drop a Mongoose-aware CLAUDE.md into your project and ship MongoDB code that scales past 100k documents.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame