Skip to main content

✏️ MongoDB CRUD Operations

Every application, no matter how fancy, boils down to four verbs: Create, Read, Update, Delete. Master these against MongoDB and you can build the data layer of almost anything. In this lesson you'll drive them with the official Node.js driver using clean async/await — the exact code that runs behind a real Express API.

Week 8 · Day 3 (Wednesday: MongoDB Basics) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Connect to MongoDB from Node.js and get a handle on a collection
  • Insert documents with insertOne and insertMany
  • Query documents with find/findOne and read the query operators $gt, $in, and $regex
  • Update documents with updateOne and the $set operator
  • Remove documents safely with deleteOne and deleteMany
  • Explain what an index is and create a basic one for faster reads

Estimated Time: 70 minutes

Practice: Write a small task-manager data layer covering all four CRUD verbs.

In This Lesson

CRUD in One Picture

CRUD is the contract between your application and the database. The app asks; the database answers. Each verb maps to one or two driver methods — learn the map and you've learned the whole surface.

graph LR App[Node.js App] -->|Create| DB[(MongoDB)] App -->|Read| DB App -->|Update| DB App -->|Delete| DB DB --> M1["insertOne / insertMany"] DB --> M2["find / findOne"] DB --> M3["updateOne / updateMany"] DB --> M4["deleteOne / deleteMany"]

Throughout this lesson we use the official MongoDB Node.js driver (the mongodb package) with async/await. This is the foundation Mongoose is built on — knowing the raw driver makes the higher-level tools far less mysterious.

Connecting from Node.js

Before any CRUD, you need a live connection and a reference to a collection. Install the driver, then keep your connection string in an environment variable — never hard-code credentials.

// Terminal:  npm install mongodb dotenv

import { MongoClient } from 'mongodb';
import 'dotenv/config';

// The connection string lives in .env, e.g.
// MONGODB_URI=mongodb+srv://user:pass@cluster0.xxxx.mongodb.net
const client = new MongoClient(process.env.MONGODB_URI);

await client.connect();                 // open the connection once, up front
const db = client.db('shop');           // pick a database
const products = db.collection('products'); // get a collection handle

console.log('Connected — ready for CRUD');

⚠️ Keep secrets out of your code

Put MONGODB_URI in a .env file and add .env to .gitignore. A connection string contains a username and password — committing it to Git is one of the most common ways databases get breached. The modern driver no longer needs the old useNewUrlParser/useUnifiedTopology flags; they're deprecated and can be dropped.

In the CRUD examples below, assume products is the collection handle from above. All driver methods return promises, so we await them.

Create: insertOne & insertMany

Create adds new documents. Use insertOne for a single document and insertMany for a batch — batching is dramatically faster than looping one insert at a time.

Insert a single document

const result = await products.insertOne({
  name: 'Smartphone X1',
  price: 699.99,
  category: 'Electronics',
  specs: { storage: '128GB', color: 'Black' },
  tags: ['phone', 'mobile'],
  inStock: true,
  createdAt: new Date()            // a real BSON Date, not a string
});

console.log(result.insertedId);    // → the auto-generated ObjectId

Output

{ acknowledged: true, insertedId: new ObjectId("665f0a12ab34cd56ef789012") }

If you don't provide an _id, MongoDB creates an ObjectId for you and hands it back in result.insertedId. The insert is atomic — it fully succeeds or fully fails.

Insert many documents

const result = await products.insertMany([
  { name: 'Laptop Pro', price: 1299.99, category: 'Electronics', inStock: true },
  { name: 'Wireless Earbuds', price: 149.99, category: 'Audio', inStock: true },
  { name: 'Smart Watch', price: 299.99, category: 'Wearables', inStock: false }
]);

console.log(result.insertedCount);  // → 3
console.log(result.insertedIds);    // → { '0': ObjectId(...), '1': ..., '2': ... }

💡 ordered inserts

By default insertMany is ordered: it stops at the first failing document. Pass { ordered: false } to keep going and insert every valid document, collecting the errors at the end — useful for bulk imports where one bad row shouldn't sink the whole batch.

Read: find, findOne & operators

Read is where you'll spend most of your time. You describe the documents you want with a query filter — a plain object — and MongoDB returns the matches.

The basics

// find() returns a CURSOR, not an array — call toArray() to materialize it
const all = await products.find().toArray();

// A filter is just an object: field → value means "equals"
const electronics = await products.find({ category: 'Electronics' }).toArray();

// findOne() returns a single document (or null), no cursor needed
const one = await products.findOne({ name: 'Laptop Pro' });

⚠️ find gives you a cursor

find() does not return documents directly — it returns a cursor, a pointer that fetches results lazily. Call .toArray() for small result sets, or iterate with for await (const doc of cursor) for large ones so you don't load everything into memory at once.

Query operators

Equality only gets you so far. Query operators — keys that start with $ — express ranges, lists, and patterns. Here are the three you'll use constantly.

// $gt / $gte / $lt / $lte — comparisons
await products.find({ price: { $gt: 500 } }).toArray();        // price > 500
await products.find({ price: { $gte: 100, $lt: 300 } }).toArray(); // 100 ≤ price < 300

// $in — value is one of a list
await products.find({ category: { $in: ['Audio', 'Wearables'] } }).toArray();

// $regex — string matches a pattern ( i = case-insensitive )
await products.find({ name: { $regex: 'watch', $options: 'i' } }).toArray();

// Multiple fields in one filter are combined with AND
await products.find({ category: 'Electronics', inStock: true }).toArray();

// Dot notation reaches into embedded documents
await products.find({ 'specs.storage': '128GB' }).toArray();
OperatorMeaningExample
$gt / $gteGreater than / or equal{ price: { $gt: 500 } }
$lt / $lteLess than / or equal{ age: { $lte: 30 } }
$neNot equal{ inStock: { $ne: false } }
$inMatches any value in a list{ category: { $in: ['A','B'] } }
$regexString matches a pattern{ name: { $regex: 'pro', $options: 'i' } }

Shaping results: projection, sort, limit

const rows = await products
  .find({ inStock: true })
  .project({ name: 1, price: 1, _id: 0 })  // include name & price, drop _id
  .sort({ price: -1 })                      // -1 descending, 1 ascending
  .limit(5)                                 // top 5 only
  .toArray();

Projection keeps payloads small by returning only the fields you need, sort orders the results, and limit caps how many come back — always limit in production so a huge collection can't flood your app.

Update: updateOne & $set

Update modifies existing documents. The critical rule: an update takes two arguments — a filter (which documents) and an update (what to change) — and the change is written with update operators like $set.

// Change specific fields, leaving the rest of the document untouched
const result = await products.updateOne(
  { name: 'Smartphone X1' },                    // filter: which document
  { $set: { price: 649.99, 'specs.color': 'Blue' } }  // update: what to change
);

console.log(result.matchedCount);    // 1 — one document matched the filter
console.log(result.modifiedCount);   // 1 — one document was actually changed

⚠️ Never forget $set

Writing updateOne({ name: 'X1' }, { price: 649.99 }) without $set is a classic beginner trap — older behavior would replace the entire document with just { price: 649.99 }, wiping every other field. Always wrap your changes in an update operator. (The modern driver actually throws an error if you pass a plain object, which is a helpful guardrail — but build the habit anyway.)

More update operators

// $inc — add to a number (use a negative value to subtract)
await products.updateOne({ name: 'Laptop Pro' }, { $inc: { price: -100 } });

// $push — append to an array
await products.updateOne({ name: 'Smart Watch' }, { $push: { tags: 'sale' } });

// $unset — remove a field entirely
await products.updateOne({ name: 'Smart Watch' }, { $unset: { color: '' } });

// updateMany — apply to EVERY matching document
await products.updateMany(
  { category: 'Electronics' },
  { $set: { onSale: true } }
);

// upsert — update if found, otherwise insert a new document
await products.updateOne(
  { name: 'Tablet Mini' },
  { $set: { price: 349.99, category: 'Electronics' } },
  { upsert: true }
);

updateOne vs updateMany

updateOne changes the first matching document; updateMany changes all of them. Reach for updateOne by default — it's the safer choice when you mean to touch a single record.

Delete: deleteOne & deleteMany

Delete removes documents. The same one-vs-many split applies, and the filter is everything — a mistake here is unrecoverable.

// Remove a single document
const result = await products.deleteOne({ name: 'Wireless Earbuds' });
console.log(result.deletedCount);   // → 1

// Remove every matching document
await products.deleteMany({ inStock: false });

// findOneAndDelete returns the document it removed — handy for logging/undo
const removed = await products.findOneAndDelete({ name: 'Smart Watch' });
console.log('Deleted:', removed?.name);

⚠️ deleteMany({}) empties the collection

An empty filter matches everything. deleteMany({}) deletes every document in the collection — there is no confirmation and no trash bin. Double-check your filter before every delete, and for important data prefer a soft delete (a { $set: { deleted: true } } flag) so records can be recovered.

Indexes: Faster Reads

Without an index, MongoDB answers a query by scanning every document in the collection — fine for 50 documents, catastrophic for 5 million. An index is a sorted data structure that lets the database jump straight to matches, exactly like the index at the back of a textbook lets you find a topic without reading every page.

// Create an ascending index on a single field
await products.createIndex({ category: 1 });   // 1 ascending, -1 descending

// Compound index — supports queries that filter/sort on both fields
await products.createIndex({ category: 1, price: -1 });

// Unique index — the database rejects duplicate values
await products.createIndex({ sku: 1 }, { unique: true });

// See what indexes exist
console.log(await products.indexes());

💡 Index the fields you query

A good rule to start: add an index for any field you frequently filter or sort on. Every collection already has an index on _id. Don't over-index, though — each index speeds up reads but adds a little cost to every write and uses storage. Index deliberately, based on your real queries.

Practice & Quiz

🏋️ Exercise 1: A task-manager data layer

Goal: Using a tasks collection handle, write four functions — one per CRUD verb. A task looks like { title, done, priority, createdAt }.

// TODO: implement each function with the driver
async function addTask(title, priority) { /* insert a new, not-done task */ }
async function getOpenTasks() { /* all tasks where done is false, newest first */ }
async function completeTask(id) { /* set done: true for the task with this _id */ }
async function removeTask(id) { /* delete the task with this _id */ }
💡 Hint

To match by _id you must wrap the string in new ObjectId(id) — a filter of { _id: id } with a plain string won't match an ObjectId. Import ObjectId from the mongodb package.

✅ Solution
import { ObjectId } from 'mongodb';

async function addTask(title, priority) {
  const result = await tasks.insertOne({
    title,
    priority,
    done: false,
    createdAt: new Date()
  });
  return result.insertedId;
}

async function getOpenTasks() {
  return tasks
    .find({ done: false })
    .sort({ createdAt: -1 })   // newest first
    .toArray();
}

async function completeTask(id) {
  const result = await tasks.updateOne(
    { _id: new ObjectId(id) },
    { $set: { done: true, completedAt: new Date() } }
  );
  return result.modifiedCount;  // 1 if it worked, 0 if nothing matched
}

async function removeTask(id) {
  const result = await tasks.deleteOne({ _id: new ObjectId(id) });
  return result.deletedCount;
}

🏋️ Exercise 2: Build a query filter

Goal: Write a single find filter that returns products in the Electronics or Audio category, priced under 800, that are in stock. Return only name and price, sorted cheapest first.

✅ Solution
const rows = await products
  .find({
    category: { $in: ['Electronics', 'Audio'] },
    price: { $lt: 800 },
    inStock: true
  })
  .project({ name: 1, price: 1, _id: 0 })
  .sort({ price: 1 })
  .toArray();

🎯 Quick Quiz

Question 1: What does products.find({ category: 'Audio' }) return before you call .toArray()?

Question 2: Which update keeps a document's other fields intact while changing only its price?

Question 3: Why add an index to a field you query often?

Best Practices & Pitfalls

✅ Do

  • Use insertMany and bulk writes instead of looping single inserts
  • Always .limit() and .project() production queries to keep payloads small
  • Wrap changes in update operators ($set, $inc, $push)
  • Wrap _id strings in new ObjectId(...) before filtering
  • Index the fields you filter and sort on most

❌ Don't

  • Call deleteMany({}) or updateMany({}, ...) without triple-checking the empty filter
  • Load huge collections with .toArray() — iterate the cursor instead
  • Assume an update matched — check matchedCount / modifiedCount
  • Leave leading-wildcard regexes like /^.*x/ in hot queries — they can't use an index

✅ Always check the result object

Every write returns a result: insertedId, matchedCount, modifiedCount, deletedCount. Inspecting these tells you whether the operation actually did what you expected — an update with matchedCount: 0 silently changed nothing, which is a bug waiting to be noticed.

Summary

🎉 Key Takeaways

  • Create with insertOne / insertMany — MongoDB fills in the _id
  • Read with find (returns a cursor → .toArray()) and findOne, refined by operators like $gt, $in, $regex
  • Update with a filter + an update operator such as $set — never a bare object
  • Delete with deleteOne / deleteMany — guard the filter carefully
  • Shape reads with project, sort, and limit
  • Indexes turn full-collection scans into fast lookups on the fields you query

📚 Additional Resources

🚀 What's Next?

You've been connecting with a raw URI string. Next you'll set up a real cloud database properly, from account to connection: MongoDB Atlas Setup — the free-tier cluster that will host every project from here on.

🎉 You can drive a database now!

Create, read, update, delete — the four verbs behind every app you'll ever build.