Common MongoDB Mistakes

MongoDB’s document model and flexible schema make it fast to get started, but that same flexibility means the database rarely stops you from writing code that is subtly wrong. A query can compare the wrong BSON type and silently return nothing. An update can match one document when you meant to match a thousand. A document can keep growing until it slams into a hard size limit. None of these throw a loud error at the moment you make the mistake — they surface later, as missing records, slow production queries, or a confusing 500 error weeks after the code shipped. This lesson walks through the mistakes developers hit most often in mongosh and the Node.js driver, shows exactly how each one manifests, and gives you the corrected code.

Overview: How These Mistakes Happen

Most MongoDB mistakes trace back to three properties of the database that are also its biggest strengths. First, documents have no enforced schema by default: a collection can hold documents where the same field is a string in one document and a date in another, and MongoDB will not object. Second, BSON comparisons are type-aware — a string value is never considered greater than, less than, or equal to a Date value, even if they represent \”the same\” moment to a human reader. A query like { createdAt: { $gt: ISODate(\"2026-01-01\") } } will silently skip every document where createdAt was accidentally stored as a string, with no error and no warning. Third, most write operations report their result as a plain object ({ acknowledged, matchedCount, modifiedCount }) rather than throwing when the match count is lower than you expected. updateOne() matching exactly one document out of a thousand you meant to update is, as far as the driver is concerned, a complete success.

Indexes compound the problem. MongoDB will happily run find() or aggregate() against a collection with no relevant index at all — it just falls back to a full collection scan (COLLSCAN), which still returns correct results on a 500-document test collection and then takes nine seconds in production once that collection has 5 million documents. And because a single document can hold nested arrays and subdocuments, it is easy to build a data model that embeds an ever-growing array (login history, comments, audit events) directly inside a parent document, not realizing that a single BSON document is capped at 16MB. Every one of these issues is invisible during casual development and expensive in production — which is exactly why they deserve their own lesson.

Syntax: Modern vs. Legacy Methods

A large share of \”MongoDB mistakes\” found in older blog posts and Stack Overflow answers are simply calls to methods that no longer exist in the modern driver and are deprecated or removed in current mongosh. Know the mapping before you copy code from an old tutorial:

Legacy / removed Use instead Why
insert() insertOne() / insertMany() Explicit about single vs. bulk insert; removed from the Node.js driver v6+ API.
update() updateOne() / updateMany() / replaceOne() Old update() needed a { multi: true } option to affect more than one document — easy to forget.
remove() deleteOne() / deleteMany() Same single-vs-many ambiguity as update().
save() insertOne() or replaceOne(filter, doc, { upsert: true }) save() guessed insert-vs-update from the presence of _id, which was error-prone.
count() countDocuments() / estimatedDocumentCount() Old count() ignored some query shapes and was inconsistent with sharded clusters.

Examples

The three examples below show how to actually catch these mistakes as they happen, not just describe them.

Example 1: Comparing an ObjectId to a Raw String

A classic bug: reading an id from a URL parameter or JWT and querying with it directly, without converting it to an ObjectId.

// req.params.id from an Express route: \"64fa1c2b9e1d2a0012ab34cd\"
const userId = \"64fa1c2b9e1d2a0012ab34cd\";

db.users.findOne({ _id: userId });

Output:

null

The document exists, but its _id is stored as a BSON ObjectId, and BSON never treats an ObjectId as equal to a string with the same characters — they are different types. The fix is to wrap the string in ObjectId() before querying:

const userId = \"64fa1c2b9e1d2a0012ab34cd\";

db.users.findOne({ _id: ObjectId(userId) });

Output:

{
  _id: ObjectId('64fa1c2b9e1d2a0012ab34cd'),
  name: 'Priya Shah',
  email: 'priya@example.com',
  createdAt: ISODate('2024-09-08T10:15:00.000Z')
}

Example 2: Diagnosing and Fixing a Missing Index

A query that works fine in development can be doing a full collection scan without you ever noticing. explain(\"executionStats\") tells the truth:

db.orders.find({
  status: \"shipped\",
  createdAt: { $gt: ISODate(\"2026-01-01\") }
}).explain(\"executionStats\");

Output (relevant excerpt):

{
  queryPlanner: {
    winningPlan: { stage: 'COLLSCAN' }
  },
  executionStats: {
    nReturned: 412,
    totalDocsExamined: 850000,
    executionTimeMillis: 940
  }
}

stage: 'COLLSCAN' means MongoDB examined all 850,000 documents in the collection to find 412 matches. Creating a compound index on the equality field first and the range field second fixes it:

db.orders.createIndex({ status: 1, createdAt: 1 });

Output:

status_1_createdAt_1
db.orders.find({
  status: \"shipped\",
  createdAt: { $gt: ISODate(\"2026-01-01\") }
}).explain(\"executionStats\");

Output:

{
  queryPlanner: {
    winningPlan: {
      stage: 'FETCH',
      inputStage: { stage: 'IXSCAN', indexName: 'status_1_createdAt_1' }
    }
  },
  executionStats: {
    nReturned: 412,
    totalDocsExamined: 412,
    executionTimeMillis: 6
  }
}

Now MongoDB examines only the 412 matching documents instead of all 850,000, and the query drops from 940ms to 6ms.

Example 3: Ordering an Aggregation Pipeline Correctly

Aggregation stages run in the order you write them, each one passing its output documents to the next. Putting an expensive $lookup before a selective $match forces MongoDB to join every document, then throw most of the joined results away:

db.orders.aggregate([
  {
    $lookup: {
      from: \"customers\",
      localField: \"customerId\",
      foreignField: \"_id\",
      as: \"customer\"
    }
  },
  { $match: { status: \"shipped\" } },
  { $project: { _id: 1, status: 1, \"customer.name\": 1 } }
]);

Output (executionStats excerpt):

// totalDocsExamined (orders): 900000
// totalDocsExamined via $lookup (customers): 900000
// executionTimeMillis: 1800

Moving $match first lets MongoDB use the status index to shrink the working set before the join ever runs:

db.orders.aggregate([
  { $match: { status: \"shipped\" } },
  {
    $lookup: {
      from: \"customers\",
      localField: \"customerId\",
      foreignField: \"_id\",
      as: \"customer\"
    }
  },
  { $project: { _id: 1, status: 1, \"customer.name\": 1 } }
]);

Output:

// totalDocsExamined (orders): 41200
// totalDocsExamined via $lookup (customers): 41200
// executionTimeMillis: 95

How It Works Step by Step

Understanding the mechanics behind these mistakes makes them much easier to spot before they reach production:

Type-aware comparison. Internally, BSON assigns every value a type code, and comparisons in $gt, $lt, and equality matches first compare by type before comparing by value. Strings, dates, and ObjectIds occupy different positions in BSON’s type-comparison order, so a string will never satisfy a Date range query, and an ObjectId will never equal a same-looking string. This is why mismatched types don’t error — they just silently fail to match, which is more dangerous than a crash.

Query planning. When you run a query, MongoDB’s query planner looks at available indexes whose key pattern could support the query shape. If a usable index exists, it runs one or more candidate plans, caches the winner, and reports IXSCAN in explain(). If no index matches, it falls back to COLLSCAN: reading every document in storage order and evaluating the filter against each one in memory. Compound indexes should generally follow the ESR rule — Equality fields first, then Sort fields, then Range fields — so the index can narrow down as much as possible before falling back to scanning within a range.

Aggregation pipeline streaming. Each aggregation stage consumes the stream of documents produced by the previous stage and produces its own output stream for the next. An early, index-backed $match shrinks that stream before it reaches expensive stages like $lookup, $group, or $sort. Stages like $group and $sort that need to see many documents at once are also capped at 100MB of RAM per stage by default; without { allowDiskUse: true }, a pipeline that tries to sort or group too much data in memory throws an error rather than silently spilling to disk.

Common Mistakes

1. Using updateOne() When You Meant updateMany()

// Intent: cancel every pending order
db.orders.updateOne(
  { status: \"pending\" },
  { $set: { status: \"cancelled\" } }
);

Output:

{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }

This runs without error and reports success — but only the first matching document was touched; the other 186 pending orders are untouched. Always use updateMany() when the intent is to affect every matching document:

db.orders.updateMany(
  { status: \"pending\" },
  { $set: { status: \"cancelled\" } }
);

Output:

{ acknowledged: true, matchedCount: 187, modifiedCount: 187 }

2. Letting an Embedded Array Grow Without Bound

// Every login appends to the array with no limit
db.users.updateOne(
  { _id: ObjectId(\"64fa1c2b9e1d2a0012ab34cd\") },
  { $push: { loginHistory: { at: new Date(), ip: \"203.0.113.5\" } } }
);

After years of daily logins this array can approach the 16MB document size limit, and long before that it slows down every read of the user document, since the entire array loads along with the rest of the document even if you only wanted the user’s name. Cap the array with $slice, or better, move high-volume history into its own collection referenced by userId:

db.users.updateOne(
  { _id: ObjectId(\"64fa1c2b9e1d2a0012ab34cd\") },
  {
    $push: {
      loginHistory: {
        $each: [{ at: new Date(), ip: \"203.0.113.5\" }],
        $slice: -50
      }
    }
  }
);

Output:

{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }

3. Storing Dates as Strings

db.orders.insertOne({
  customerId: ObjectId(\"64fa1c2b9e1d2a0012ab34cd\"),
  status: \"shipped\",
  createdAt: \"2026-01-15\"
});

// Later, this range query silently misses the order above
db.orders.find({ createdAt: { $gt: ISODate(\"2026-01-01\") } });

Output:

// insertOne result: { acknowledged: true, insertedId: ObjectId('...') }
// find() result: does not include the order inserted above

Because BSON compares by type, a string createdAt is never $gt a Date value, so the document simply never appears in date-range results — no error, no warning. Always insert real Date objects:

db.orders.insertOne({
  customerId: ObjectId(\"64fa1c2b9e1d2a0012ab34cd\"),
  status: \"shipped\",
  createdAt: new Date(\"2026-01-15\")
});

4. Using Legacy, Removed Driver Methods

// Copied from an old tutorial - insert/update/remove are deprecated
// and removed from the modern Node.js driver's TypeScript surface
db.orders.insert({ status: \"pending\", total: 49.99 });
db.orders.update({ status: \"pending\" }, { $set: { total: 59.99 } });
db.orders.remove({ status: \"cancelled\" });

These calls may still appear to work in an interactive mongosh session but will throw in application code written against the modern Node.js driver, and update()/remove() default to affecting only a subset of matches unless extra options are passed. Use the explicit modern methods instead:

db.orders.insertOne({ status: \"pending\", total: 49.99 });
db.orders.updateOne({ status: \"pending\" }, { $set: { total: 59.99 } });
db.orders.deleteMany({ status: \"cancelled\" });

Output:

{ acknowledged: true, insertedId: ObjectId('...') }
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
{ acknowledged: true, deletedCount: 9 }

5. Pulling Whole Foreign Documents Through $lookup

// customers has 5 million documents with many large fields;
// every order now embeds the ENTIRE matching customer document
db.orders.aggregate([
  {
    $lookup: {
      from: \"customers\",
      localField: \"customerId\",
      foreignField: \"_id\",
      as: \"customer\"
    }
  }
]);

Output (one document, abbreviated):

{
  _id: ObjectId('...'), status: 'shipped',
  customer: [{ _id: ObjectId('...'), name: 'Priya Shah', email: '...',
    billingHistory: [ /* hundreds of entries */ ], preferences: { /* ... */ } }]
}

Every order document now carries the customer’s entire record, including fields the query never needed, ballooning result size and network transfer. Use the pipeline form of $lookup to project only the fields you actually need before they’re attached:

db.orders.aggregate([
  {
    $lookup: {
      from: \"customers\",
      let: { custId: \"$customerId\" },
      pipeline: [
        { $match: { $expr: { $eq: [\"$_id\", \"$$custId\"] } } },
        { $project: { name: 1, email: 1, _id: 0 } }
      ],
      as: \"customer\"
    }
  }
]);

Best Practices

  • Run .explain(\"executionStats\") on any query touching more than a few thousand documents before shipping it, and confirm you see IXSCAN, not COLLSCAN.
  • Always convert a string id to ObjectId(idString) before querying by _id — especially values coming from URL params, JWTs, or JSON request bodies.
  • Store dates as BSON Date objects (new Date(...) or ISODate(...)), never as ISO strings, so range queries and sorting behave correctly.
  • Default to updateMany()/deleteMany() whenever the intent is \”every matching document\”; reserve updateOne()/deleteOne() for cases where matching exactly one document is intentional.
  • Cap growing arrays with $slice, or move high-volume, unbounded data (logs, history, comments) into a separate referenced collection instead of embedding it.
  • Put an index-backed $match as early as possible in every aggregation pipeline, and use the pipeline form of $lookup to project only needed fields.
  • Never use the removed/deprecated methods insert(), update(), remove(), save(), or count() in new code — use their explicit modern equivalents.
  • Follow the equality-sort-range (ESR) rule when designing compound indexes.
  • Check matchedCount and modifiedCount in write results during development and in tests, not just acknowledged.

Practice Exercises

  • A teammate wrote db.orders.updateOne({ status: \"processing\" }, { $set: { status: \"shipped\" } }) intending to mark every processing order as shipped, but only one order changed. Identify the bug and rewrite the query so it updates all matching orders.
  • Given a db.reviews collection queried as db.reviews.find({ productId: someId, rating: { $gte: 4 } }).sort({ createdAt: -1 }), run explain(\"executionStats\") to check whether it uses an index, then design a compound index that follows the ESR rule for this query shape.
  • A blogPosts collection embeds a comments array directly on each post document, and popular posts are approaching the 16MB document limit. Describe how you would redesign the schema using a separate comments collection, including what field would reference the parent post.

Summary

  • MongoDB rarely throws errors for logically wrong operations — type mismatches, partial updates, and missing indexes fail silently, so you must actively check for them.
  • Compare an _id only after wrapping a string in ObjectId(); BSON never treats an ObjectId as equal to a same-looking string.
  • Store dates as BSON Date values, not strings, or range queries will silently skip documents.
  • Use explain(\"executionStats\") to confirm queries use IXSCAN rather than a full COLLSCAN.
  • Use updateMany()/deleteMany() deliberately, and check matchedCount/modifiedCount rather than assuming success means \”all documents changed.\”
  • Cap or externalize unbounded arrays before they threaten the 16MB document size limit.
  • Order aggregation pipelines to filter early and project only needed fields, especially around $lookup.
  • Replace legacy driver methods (insert, update, remove, save, count) with their modern equivalents in all new code.