MongoDB – CRUD Operations

November 5, 20246 min readUpdated 8/24/2026

Four verbs cover almost everything you will do with MongoDB: insert, find, update, delete. The syntax is small and you will have it in an afternoon. What takes longer is the habit that separates a fast collection from a slow one — updating a field rather than rewriting a document. That is most of this lesson.

Insert

Two methods, and the plural one is not just a convenience:

db.reels.insertOne({ slug: "half-court-heave", title: "Half-court heave", status: "DRAFT" })
// { acknowledged: true, insertedId: ObjectId('...') }

db.reels.insertMany([
  { slug: "a", status: "DRAFT" },
  { slug: "b", status: "DRAFT" }
])

insertMany sends one network round trip instead of many, which is the difference between seconds and minutes on a bulk load. By default it is ordered: it stops at the first failure and leaves the rest uninserted. Pass { ordered: false } and it attempts every document, reporting the failures at the end — usually what you want when importing data where a few duplicates are expected.

Remember that a collection does not need to exist first. The first insert creates it, which also means a typo creates a new empty collection rather than an error.

Find

An empty filter matches everything, and a filter document is an implicit AND of its fields:

db.reels.find({})                                     // everything
db.reels.find({ status: "PUBLISHED" })                // equality
db.reels.find({ status: "PUBLISHED", tags: "nba" })   // both conditions

db.reels.findOne({ slug: "half-court-heave-to-end-the-third" })   // one document, or null

find returns a cursor, not an array. The shell prints the first batch and offers to fetch more, but in a driver you either iterate it or call toArray(). That distinction matters: a cursor streams, so it can walk a collection larger than memory, while toArray() pulls everything at once.

The second argument is a projection — which fields to return. Use it. On a collection with large documents it is the cheapest performance win available:

db.reels.find({ status: "PUBLISHED" }, { title: 1, slug: 1 })       // these fields, plus _id
db.reels.find({ status: "PUBLISHED" }, { title: 1, _id: 0 })        // exclude _id explicitly
db.reels.find({}, { "video.posterUrl": 0 })                         // everything except this

You cannot mix inclusion and exclusion in one projection — the single exception is _id, which may always be excluded. Try it and MongoDB rejects the query, which is one of the few places it is strict.

Sorting, skipping and limiting chain onto the cursor:

db.reels.find({ status: "PUBLISHED" }).sort({ publishedAt: -1 }).limit(10)

1 is ascending, -1 descending. A sort with no supporting index has to happen in memory and is capped at 32 MB — past that the query fails outright rather than slowing down. That cap is why the indexes lesson comes before the aggregation ones.

Update — and the habit worth forming

An update takes a filter and a set of update operators. The operators are the important part:

db.reels.updateOne(
  { slug: "half-court-heave" },
  { $set: { status: "PUBLISHED", publishedAt: new Date() } }
)

db.reels.updateMany({ status: "DRAFT" }, { $set: { status: "ARCHIVED" } })

Leave out the operator and you do not get an error — on replaceOne you get a document with every other field gone. That is the classic first-week accident, and the reason updateOne now rejects a plain document argument outright.

The operators worth knowing on day one:

{ $set:   { title: "New title" } }        // set or create a field
{ $unset: { scheduledFor: "" } }          // remove a field entirely
{ $inc:   { "stats.views": 1 } }          // add to a number, atomically
{ $push:  { tags: "clutch" } }            // append to an array
{ $addToSet: { tags: "clutch" } }         // append only if absent
{ $pull:  { collectionIds: someId } }     // remove matching array elements
{ $currentDate: { updatedAt: true } }     // stamp server time

Why $inc is not just shorthand

This is the point of the lesson. Counting a view could be written as read, add one, save:

// DON'T. Two operations with a gap in the middle.
const reel = db.reels.findOne({ _id: id })
db.reels.replaceOne({ _id: id }, { ...reel, stats: { ...reel.stats, views: reel.stats.views + 1 } })

Two things are wrong with it. It loses increments: two requests that read the same value both write the same value back, and one view vanishes. And it rewrites the entire document to change one number — on a reel carrying a description and a tag array, that is a lot of bytes for eight bits of information.

$inc has neither problem. It is applied by the server, atomically, to one field:

db.reels.updateOne({ _id: id }, { $inc: { "stats.views": 1 } })

Single-document updates are atomic in MongoDB, so no transaction is involved. This is ReelCMS doing exactly that from Java — note that it names one field rather than saving an object:

public void incrementStat(String reelId, String statField, long delta) {
    mongo.updateFirst(
            Query.query(Criteria.where("_id").is(reelId)),
            new Update().inc("stats." + statField, delta),
            Reel.class);
}

Upserts

upsert: true means “update it, or insert it if it is not there”. The inserted document is built from the filter plus the update:

db.dailyStats.updateOne(
  { reelId: id, day: "2025-11-05" },
  { $inc: { views: 1 } },
  { upsert: true }
)

First call creates { reelId, day, views: 1 }; every call after increments. That one line replaces a check-then-write that would have had a race in the gap. Give the filter fields a unique index, though — two concurrent upserts can both find nothing and both insert.

Getting the document back

updateOne returns counts, not the document. When you need the new value — a counter you are about to display, or an id you just allocated — findOneAndUpdate does both in one atomic operation:

db.reels.findOneAndUpdate(
  { _id: id },
  { $inc: { "stats.views": 1 } },
  { returnDocument: "after" }        // "before" is the default
)

The default returning the old document surprises people, and it is the right default: that is what makes the method a compare-and-swap primitive. Reading the value back with a separate findOne would reintroduce exactly the race that $inc removed.

Delete

db.reels.deleteOne({ slug: "half-court-heave" })
db.reels.deleteMany({ status: "ARCHIVED" })
db.reels.deleteMany({})            // every document, collection survives
db.reels.drop()                    // collection and its indexes, gone

There is no cascade. Deleting a reel does not touch the comments that reference it — MongoDB has no foreign keys, so cleaning up is the application’s job. Miss it and you accumulate orphans that nothing will ever read and nothing will ever remove.

Many teams never hard-delete at all, setting a deletedAt field instead and filtering it out on read. That keeps referential clean-up optional and makes an accidental delete recoverable.

Bulk writes

When you have a mixed batch, one bulkWrite beats a loop of individual calls by an order of magnitude, because it is one round trip:

db.reels.bulkWrite([
  { insertOne: { document: { slug: "new-clip", status: "DRAFT" } } },
  { updateOne: { filter: { slug: "old-clip" }, update: { $set: { status: "ARCHIVED" } } } },
  { deleteOne: { filter: { slug: "junk" } } }
], { ordered: false })

If you ever find yourself writing for (const doc of docs) db.c.updateOne(...), this is what you wanted instead.

The short version

  • insertMany is one round trip; {ordered: false} keeps going past a failure.
  • find returns a cursor. Project the fields you need.
  • Never mix inclusion and exclusion in a projection, except for _id.
  • An in-memory sort is capped at 32 MB and fails rather than slows.
  • Always update with an operator. $inc is atomic and touches one field; read → modify → save loses writes and rewrites everything.
  • upsert: true removes a race, but wants a unique index behind it.
  • No cascade on delete. Cleaning up references is your job.