Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 005 / 100

CRUD operations in MongoDB: patterns, pitfalls and fixes

Insert, find, update and delete.

Expona Admin8 min read

Introduction

After five days into MongoDB fundamentals, you've learned about document structure and schema design. Now comes the practical reality: actually reading, writing, and deleting data at scale. CRUD operations in MongoDB are deceptively simple on the surface—insertOne, find, updateOne, deleteOne—but the patterns you choose today will either keep your application responsive or bury it under performance debt. In this article, we'll walk through each operation type, show you the patterns that work in production, and highlight the pitfalls we've seen team members (and ourselves) stumble into.

Insert — insertOne and insertMany

The insert operation is often the first thing a developer reaches for, and it's where many teams establish good or bad habits. MongoDB gives you two main methods: insertOne for a single document and insertMany for multiple documents at once.

Let's start with a real scenario. You're building an e-commerce platform and need to record a product:

javascript

const db = client.db('ecommerce');
const products = db.collection('products');

// Single insert
const result = await products.insertOne({
  name: "Mug",
  price: 12,
  tags: ["kitchen"],
  createdAt: new Date(),
  inventory: 100
});

console.log(result.insertedId); // ObjectId automatically generated

MongoDB automatically generates an _id field if you don't provide one. That's convenient, but notice we're adding createdAt—this pattern is essential for auditing and sorting by insertion order.

Now imagine you're importing 10,000 products from a CSV. You could loop and call insertOne 10,000 times. Don't. Use insertMany instead:

javascript

const productsToInsert = [
  { name: "Mug", price: 12, tags: ["kitchen"], createdAt: new Date() },
  { name: "Plate", price: 8, tags: ["kitchen"], createdAt: new Date() },
  { name: "Fork", price: 2, tags: ["cutlery"], createdAt: new Date() }
  // ... 9,997 more documents
];

const result = await products.insertMany(productsToInsert, { ordered: false });
console.log(`Inserted ${result.insertedCount} documents`);

The ordered: false flag is important here. By default, insertMany stops at the first error. If you're importing data and one document has a duplicate key, the entire operation halts. With ordered: false, MongoDB continues inserting valid documents and reports errors separately—better for bulk operations where a few failures are acceptable.

Find — Filters are documents too

Finding data is where most of your application logic will live. The find operation takes a filter (which is just a document) and returns a cursor—a lazy iterator over matching documents.

Here's the fundamental pattern:

javascript

const db = client.db('ecommerce');
const products = db.collection('products');

// Find products under $20
const results = await products
  .find({ price: { $lt: 20 } })
  .sort({ price: 1 })
  .limit(10)
  .toArray();

console.log(results);

Let's break this down. The filter { price: { $lt: 20 } } is a MongoDB query operator expression. $lt means "less than." You can chain operators:

javascript

// Find products between $5 and $20
const results = await products
  .find({ 
    price: { $gte: 5, $lte: 20 },
    tags: { $in: ["kitchen", "cutlery"] }
  })
  .toArray();

The $in operator checks if the tags field contains any value in the array. This is powerful for e-commerce filters where you let users select multiple categories.

Now, a real-world scenario: your product listing page loads. You need pagination, sorting, and filtering. Here's the pattern:

javascript

async function searchProducts(filters, page = 1, pageSize = 20) {
  const skip = (page - 1) * pageSize;
  
  const query = {};
  
  if (filters.minPrice !== undefined) {
    query.price = { ...query.price, $gte: filters.minPrice };
  }
  if (filters.maxPrice !== undefined) {
    query.price = { ...query.price, $lte: filters.maxPrice };
  }
  if (filters.tags && filters.tags.length > 0) {
    query.tags = { $in: filters.tags };
  }
  if (filters.search) {
    query.name = { $regex: filters.search, $options: 'i' }; // case-insensitive
  }
  
  const results = await products
    .find(query)
    .sort({ price: 1 })
    .skip(skip)
    .limit(pageSize)
    .toArray();
  
  const total = await products.countDocuments(query);
  
  return { results, total, page, pageSize };
}

Notice we build the query object conditionally. We also fetch the total count for pagination metadata. This is standard for UI pagination, but remember: countDocuments scans the entire collection unless the query is optimized by an index. For large datasets, consider caching the total or using approximate counts.

Update — Use operators like $set and $inc — never replace by accident

Updates are where we see the most production issues. MongoDB's update model is flexible, but flexibility without discipline causes data loss.

There are two update verbs: partial updates and replacements. Always use partial updates with operators in production.

Here's the pattern:

javascript

// GOOD: Partial update with $set
await products.updateOne(
  { _id: ObjectId("...") },
  { $set: { price: 15, lastModified: new Date() } }
);

// BAD: Full replacement (this wipes all other fields!)
await products.updateOne(
  { _id: ObjectId("...") },
  { price: 15, lastModified: new Date() }
);

See the difference? The bad example replaces the entire document with just those two fields. Your tags, inventory, createdAt—all gone. Use $set to update only the fields you intend to change.

Common operators for updates include:

  • $set — set a field to a value
  • $inc — increment a numeric field
  • $push — append to an array
  • $pull — remove from an array
  • $unset — remove a field
  • $currentDate — set to current timestamp

A real scenario: customer places an order, and we need to decrement inventory and record the order:

javascript

async function processOrder(productId, quantity, orderId) {
  // Atomic decrement of inventory
  const result = await products.findOneAndUpdate(
    { _id: ObjectId(productId), inventory: { $gte: quantity } },
    {
      $inc: { inventory: -quantity },
      $push: { 
        orders: { 
          orderId: ObjectId(orderId), 
          quantity, 
          date: new Date() 
        }
      },
      $set: { lastUpdated: new Date() }
    },
    { returnDocument: 'after' }
  );
  
  if (!result.value) {
    throw new Error('Insufficient inventory');
  }
  
  return result.value;
}

Notice findOneAndUpdate with returnDocument: 'after'. This returns the updated document, which is useful for confirming the update succeeded. Also notice the filter includes inventory: { $gte: quantity }—this is optimistic locking. If inventory is insufficient, the query matches zero documents and returns null. This prevents overselling.

For bulk updates, use updateMany:

javascript

// Apply a 10% discount to all products in the "clearance" category
await products.updateMany(
  { tags: { $in: ["clearance"] } },
  {
    $mul: { price: 0.9 }, // multiply price by 0.9
    $set: { discountedAt: new Date() }
  }
);

Delete — deleteOne and deleteMany with precise filters

Deletion is the irreversible operation. Take your time with filters here.

deleteOne removes a single document:

javascript

// Delete a specific product
await products.deleteOne({ _id: ObjectId("...") });

// Delete the first (oldest) product under $5
await products.deleteOne({ price: { $lt: 5 } });

deleteMany removes all matching documents:

javascript

// Delete all expired coupons
await coupons.deleteMany({ expiresAt: { $lt: new Date() } });

// Delete all products in a discontinued category
await products.deleteMany({ tags: { $in: ["discontinued"] } });

A production pattern we use: soft deletes. Instead of actually removing data, mark it as deleted:

javascript

// Instead of deleteOne(filter), do:
await products.updateOne(
  filter,
  {
    $set: { deletedAt: new Date(), status: 'deleted' },
    $unset: { publishedAt: 1 } // optionally remove publish timestamp
  }
);

// When querying, always exclude deleted docs:
const activeProducts = await products
  .find({ deletedAt: { $exists: false } })
  .toArray();

Soft deletes buy you recovery time if a bug causes accidental bulk deletes. You can restore or audit what happened. It also preserves referential integrity—if a customer places an order referencing a product ID, hard-deleting the product severs that link.

Common mistakes

  • Looping instead of batching. Calling insertOne in a loop 10,000 times is 10,000 network round-trips. Use insertMany. Same applies to deleteMany instead of looping deleteOne.
  • Full document replacement on update. Omitting $set silently wipes fields. Make it a code review habit to flag updates without operators.
  • Unindexed queries on large collections. A find({ tags: { $in: [...] } }) on 10 million documents without an index on tags will scan the entire collection. Always check execution plans with explain().
  • No filter in deleteMany. Accidentally calling deleteMany({}) deletes the entire collection. Use soft deletes as a safety net.
  • Ignoring write concern. By default, inserts acknowledge after the driver receives the ack from MongoDB, not after disk flush. For critical operations, consider { writeConcern: { w: 'majority' } } to wait for replication.

Key takeaways

  • Use insertMany with ordered: false for bulk imports; always include createdAt for auditability.
  • Build filters as objects and compose them conditionally; remember filters are documents too.
  • Always use update operators ($set, $inc, etc.) and never rely on full replacement in production code.
  • Prefer soft deletes (marking with deletedAt) over hard deletes for recovery and referential integrity.
  • Index your query filters and always verify with explain()` before considering a query "done."
  • Batch operations reduce network round-trips; a single insertMany beats a loop of insertOne by orders of magnitude.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles