CRUD Operations — insertOne, insertMany, find, updateOne, deleteOne

CRUD — Create, Read, Update, Delete — covers the four fundamental database operations every application performs. MongoDB’s query language (MQL) expresses all four as method calls on collection objects. Every Mongoose method you will use in Express controllers translates directly to a native MongoDB operation. Understanding the raw MongoDB CRUD API lets you write better Mongoose code, debug driver errors, profile queries in Compass, and migrate confidently if you ever use the native driver without Mongoose. This lesson covers every CRUD method, its syntax, options, and return values.

CRUD Method Overview

Operation Single Document Multiple Documents
Create insertOne(doc) insertMany([docs])
Read findOne(filter) find(filter)
Update updateOne(filter, update) updateMany(filter, update)
Replace replaceOne(filter, replacement)
Delete deleteOne(filter) deleteMany(filter)
Find + Update findOneAndUpdate(filter, update, opts)
Find + Delete findOneAndDelete(filter, opts)
Upsert updateOne(filter, update, { upsert: true }) updateMany(..., { upsert: true })

Update Operators

Operator Purpose Example
$set Set field values { $set: { status: 'done' } }
$unset Remove fields { $unset: { dueDate: '' } }
$inc Increment/decrement a number { $inc: { viewCount: 1 } }
$push Add element to array { $push: { tags: 'urgent' } }
$pull Remove elements from array matching condition { $pull: { tags: 'urgent' } }
$addToSet Add element to array only if not present { $addToSet: { tags: 'urgent' } }
$pop Remove first (-1) or last (1) array element { $pop: { history: -1 } }
$rename Rename a field { $rename: { 'oldName': 'newName' } }
$currentDate Set field to current date { $currentDate: { updatedAt: true } }
$min / $max Update only if new value is lower/higher { $min: { score: 50 } }

findOneAndUpdate Options

Option Type Default Effect
returnDocument string 'before' 'after' returns the updated document; 'before' returns original
upsert boolean false Insert if no document matches the filter
projection object all fields Select which fields to return
sort object If multiple documents match, update the first by this sort order
session ClientSession Run within a transaction
Note: updateOne() and updateMany() require update operators like $set — you cannot pass a plain replacement document. Passing { status: 'done' } without $set throws an error in modern MongoDB drivers. If you want to replace the entire document, use replaceOne(). In Mongoose, Model.findByIdAndUpdate(id, { $set: { status: 'done' } }, { new: true }) is the equivalent of findOneAndUpdate with returnDocument: 'after'.
Tip: Use findOneAndUpdate() with returnDocument: 'after' (or Mongoose’s { new: true }) when your API needs to return the updated document to the client. This single operation is atomic — it guarantees the returned document reflects the update. Using a separate updateOne() followed by findOne() introduces a race condition where another process could modify the document between the two calls.
Warning: deleteMany({}) with an empty filter deletes every document in the collection — the MongoDB equivalent of DELETE FROM table without a WHERE clause. Always double-check your filter before running bulk deletes. During development, use MongoDB Compass to preview which documents a filter matches before executing a delete. In production, consider soft deletes ($set: { deletedAt: new Date() }) so records can be recovered.

Complete CRUD Examples

// Using the native MongoDB driver (no Mongoose)
// const { MongoClient } = require('mongodb');
// const db = client.db('taskmanager');
// const tasks = db.collection('tasks');

// ── CREATE ────────────────────────────────────────────────────────────────

// insertOne — insert a single document
const result = await tasks.insertOne({
    title:     'Complete MEAN Stack chapter',
    priority:  'high',
    status:    'pending',
    userId:    new ObjectId('64a1f2b3c8e4d5f6a7b8c9d1'),
    tags:      ['learning', 'nodejs'],
    createdAt: new Date(),
});
console.log(result.insertedId);       // ObjectId("64a1f2b3c8e4d5f6a7b8c9d0")
console.log(result.acknowledged);     // true

// insertMany — insert multiple documents efficiently
const bulkResult = await tasks.insertMany([
    { title: 'Task 1', status: 'pending',     priority: 'low',  createdAt: new Date() },
    { title: 'Task 2', status: 'in-progress', priority: 'high', createdAt: new Date() },
    { title: 'Task 3', status: 'completed',   priority: 'medium', createdAt: new Date() },
], { ordered: false });  // ordered: false continues on error instead of stopping
console.log(bulkResult.insertedCount);   // 3
console.log(bulkResult.insertedIds);     // { 0: ObjectId, 1: ObjectId, 2: ObjectId }

// ── READ ──────────────────────────────────────────────────────────────────

// findOne — returns first matching document or null
const task = await tasks.findOne({ _id: new ObjectId('64a1f2b3c8e4d5f6a7b8c9d0') });
const highTask = await tasks.findOne({ priority: 'high', status: 'pending' });

// find — returns a cursor (iterable) of all matching documents
// Always convert to array or iterate with for await
const allTasks    = await tasks.find({}).toArray();
const pendingHigh = await tasks.find({ status: 'pending', priority: 'high' }).toArray();

// find with sort, skip, limit
const page1 = await tasks
    .find({ userId: new ObjectId('...') })
    .sort({ createdAt: -1 })   // -1 = descending, 1 = ascending
    .skip(0)
    .limit(10)
    .toArray();

// Count matching documents
const total = await tasks.countDocuments({ status: 'pending' });

// ── UPDATE ────────────────────────────────────────────────────────────────

// updateOne — update the first matching document
const updateResult = await tasks.updateOne(
    { _id: new ObjectId('64a1f2b3c8e4d5f6a7b8c9d0') },   // filter
    {
        $set:         { status: 'in-progress', updatedAt: new Date() },
        $push:        { tags: 'urgent' },
        $inc:         { viewCount: 1 },
    }
);
console.log(updateResult.matchedCount);   // 1 (documents matched)
console.log(updateResult.modifiedCount);  // 1 (documents actually changed)

// updateMany — update all matching documents
const bulkUpdate = await tasks.updateMany(
    { status: 'pending', priority: 'high' },
    { $set: { flagged: true, updatedAt: new Date() } }
);
console.log(bulkUpdate.modifiedCount);    // number of documents updated

// findOneAndUpdate — update AND return document atomically
const updatedTask = await tasks.findOneAndUpdate(
    { _id: new ObjectId('64a1f2b3c8e4d5f6a7b8c9d0') },
    { $set: { status: 'completed', completedAt: new Date() } },
    { returnDocument: 'after' }    // return the document AFTER the update
);
// updatedTask is the updated document (not a result object)

// Upsert — insert if no match found
const upsertResult = await tasks.updateOne(
    { externalId: 'ext-task-999' },
    {
        $set:         { title: 'Synced from external system', updatedAt: new Date() },
        $setOnInsert: { createdAt: new Date(), status: 'pending' },
    },
    { upsert: true }
);
console.log(upsertResult.upsertedId);    // ObjectId if inserted, null if updated

// ── DELETE ────────────────────────────────────────────────────────────────

// deleteOne — delete first matching document
const deleteResult = await tasks.deleteOne({ _id: new ObjectId('64a1f2b3c8e4d5f6a7b8c9d0') });
console.log(deleteResult.deletedCount);  // 1

// deleteMany — delete all matching documents
const bulkDelete = await tasks.deleteMany({ status: 'completed', createdAt: { $lt: oneYearAgo } });
console.log(bulkDelete.deletedCount);    // number deleted

// findOneAndDelete — delete and return the deleted document
const deletedTask = await tasks.findOneAndDelete(
    { _id: new ObjectId('64a1f2b3c8e4d5f6a7b8c9d0') }
);
// deletedTask is the document as it was before deletion

// ── SOFT DELETE (preferred for production) ────────────────────────────────
await tasks.updateOne(
    { _id: new ObjectId('64a1f2b3c8e4d5f6a7b8c9d0') },
    { $set: { deletedAt: new Date(), deletedBy: req.user.id } }
);
// Then filter all queries to exclude soft-deleted documents:
const activeTasks = await tasks.find({ deletedAt: { $exists: false } }).toArray();

Mongoose Equivalents

// Native Driver vs Mongoose — side-by-side

// insertOne
await tasks.insertOne(doc)
await Task.create(doc)               // Mongoose — also runs validators

// findOne
await tasks.findOne({ _id: id })
await Task.findById(id)              // Mongoose shorthand
await Task.findOne({ _id: id })      // Mongoose equivalent

// find (all)
await tasks.find({}).toArray()
await Task.find({})                  // Mongoose — returns array (not cursor)

// updateOne
await tasks.updateOne(filter, { $set: update })
await Task.updateOne(filter, { $set: update })          // same syntax
await Task.findByIdAndUpdate(id, { $set: update }, { new: true })  // + return updated

// deleteOne
await tasks.deleteOne({ _id: id })
await Task.deleteOne({ _id: id })
await Task.findByIdAndDelete(id)     // Mongoose shorthand

// countDocuments
await tasks.countDocuments(filter)
await Task.countDocuments(filter)    // identical

How It Works

Step 1 — Filters Are Plain JavaScript Objects

Every MongoDB operation takes a filter as its first argument — a plain JavaScript object where each key is a field name and each value is an exact match condition (or a query operator for more complex conditions). { status: 'pending', priority: 'high' } matches all documents where status is exactly ‘pending’ AND priority is exactly ‘high’. Multiple fields in the filter are implicitly ANDed together.

Step 2 — Update Operators Describe the Change, Not the New State

updateOne() takes a description of what to change, not a replacement. { $set: { status: 'done' } } means “set the status field to ‘done’ and leave everything else untouched.” This is fundamentally different from SQL’s UPDATE ... SET status = 'done' which also leaves other fields untouched, but different from replaceOne() which swaps the entire document. Using the wrong update method — especially missing $set — is one of the most common MongoDB bugs.

Step 3 — find() Returns a Cursor

Unlike findOne() which returns a document immediately, find() returns a cursor — a pointer to the result set. Cursors are lazy — MongoDB does not execute the query until you consume the cursor with .toArray(), .next(), or a for await...of loop. Chaining .sort(), .skip(), and .limit() before consuming the cursor builds the query efficiently — all options are sent to MongoDB in a single request.

Step 4 — findOneAndUpdate Is Atomic

MongoDB guarantees that findOneAndUpdate() — the find and the update — happens atomically. No other operation can see the document in its intermediate state between the find and the update. This is crucial for operations like “mark task as complete and return the updated task” — a separate updateOne followed by findOne could return stale data if another request modified the task between the two calls.

Step 5 — insertMany with ordered: false Maximises Throughput

By default, insertMany() is ordered — if one document fails to insert (duplicate key, validation error), all remaining inserts are aborted. Setting { ordered: false } tells MongoDB to continue inserting the remaining documents even after a failure. This is appropriate for bulk imports where you want to insert as many records as possible and collect all errors at the end rather than stopping at the first one.

Real-World Example: Task Service Methods

// services/task.service.js — raw MongoDB driver equivalents
// (Mongoose version shown in controllers)
const { ObjectId } = require('mongodb');

class TaskService {
    constructor(db) {
        this.collection = db.collection('tasks');
    }

    async findAllForUser(userId, { status, priority, page = 1, limit = 10, sort = '-createdAt' } = {}) {
        const filter = { userId: new ObjectId(userId), deletedAt: { $exists: false } };
        if (status)   filter.status   = status;
        if (priority) filter.priority = priority;

        const sortObj = sort.startsWith('-')
            ? { [sort.slice(1)]: -1 }
            : { [sort]: 1 };

        const [data, total] = await Promise.all([
            this.collection
                .find(filter)
                .sort(sortObj)
                .skip((page - 1) * limit)
                .limit(limit)
                .toArray(),
            this.collection.countDocuments(filter),
        ]);

        return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
    }

    async create(userId, taskData) {
        const doc = {
            ...taskData,
            userId:    new ObjectId(userId),
            status:    'pending',
            createdAt: new Date(),
            updatedAt: new Date(),
        };
        const result = await this.collection.insertOne(doc);
        return { ...doc, _id: result.insertedId };
    }

    async markComplete(taskId, userId) {
        return this.collection.findOneAndUpdate(
            { _id: new ObjectId(taskId), userId: new ObjectId(userId) },
            { $set: { status: 'completed', completedAt: new Date(), updatedAt: new Date() } },
            { returnDocument: 'after' }
        );
    }

    async softDelete(taskId, userId) {
        const result = await this.collection.updateOne(
            { _id: new ObjectId(taskId), userId: new ObjectId(userId) },
            { $set: { deletedAt: new Date(), updatedAt: new Date() } }
        );
        return result.modifiedCount === 1;
    }
}

Common Mistakes

Mistake 1 — Using updateOne without $set (replaces the document)

❌ Wrong — passes replacement document instead of update operators:

// This DOES NOT work in modern MongoDB drivers (throws error)
// In older drivers it replaced the entire document (removing all other fields)
await tasks.updateOne({ _id: id }, { status: 'completed' });  // Error!

✅ Correct — always use an update operator:

await tasks.updateOne({ _id: id }, { $set: { status: 'completed' } });

Mistake 2 — Forgetting to await find().toArray()

❌ Wrong — cursor object logged instead of documents array:

const tasks = tasks.find({ status: 'pending' });  // returns Cursor, not array!
console.log(tasks.length);  // undefined — cursor has no .length

✅ Correct — consume the cursor:

const tasks = await tasks.find({ status: 'pending' }).toArray();
console.log(tasks.length);  // 5

Mistake 3 — Not using the new: true option in findOneAndUpdate

❌ Wrong — returns the old document before the update:

const task = await Task.findByIdAndUpdate(id, { $set: { status: 'done' } });
res.json(task);  // returns document with status = 'pending' (old value!)

✅ Correct — use new: true to return the updated document:

const task = await Task.findByIdAndUpdate(id, { $set: { status: 'done' } }, { new: true });
res.json(task);  // returns document with status = 'done'

Quick Reference

Task MongoDB Driver Mongoose Equivalent
Insert one insertOne(doc) Model.create(doc)
Insert many insertMany([docs]) Model.insertMany([docs])
Find by ID findOne({ _id: new ObjectId(id) }) Model.findById(id)
Find all find(filter).toArray() Model.find(filter)
Update fields updateOne(filter, { $set: data }) Model.updateOne(filter, { $set: data })
Update + return findOneAndUpdate(f, u, { returnDocument: 'after' }) Model.findByIdAndUpdate(id, u, { new: true })
Delete one deleteOne({ _id: id }) Model.findByIdAndDelete(id)
Count countDocuments(filter) Model.countDocuments(filter)

🧠 Test Yourself

A Mongoose call uses findByIdAndUpdate(id, { $set: { status: 'done' } }). The API returns the old status. What option must be added to return the updated document?