FullStack Course LogoFullStack Course
Module: MongoDB
MongoDB·136·16 MIN READ

136: MongoDB CRUD and Query Language — Filters, Operators, Arrays, Projection, Cursors, Bulk Writes, and Safe Updates

TOPICS COVERED: MongoDB CRUD and Query Language — Filters, Operators, Arrays, Projection, Cursors, Bulk Writes, and Safe Updates

Learning objectives

You will learn to:

  • insert one/many documents;
  • query with comparison, logical, element, array, and regex operators;
  • understand missing versus null;
  • use projection;
  • sort/limit/skip;
  • work with cursors;
  • count documents;
  • update with atomic operators;
  • use array update operators;
  • replace documents;
  • delete safely;
  • use upsert and bulkWrite;
  • understand query injection risks;
  • build safe pagination/query filters.

This lesson stays focused on the CRUD and query features you use while building an application. The useful goal is not to memorize every operator. It is to understand what a filter means, what MongoDB returns, and which choices become risky when the data set or number of concurrent clients grows.

Insert

When an application creates a document, it usually sends a complete initial shape rather than relying on every field being added later. This example records tenant ownership, user-facing data, and timestamps together:

javascript
db.tasks.insertOne({
  tenantId: ObjectId("..."),
  title: "Learn CRUD",
  completed: false,
  priority: "normal",
  tags: ["mongodb"],
  createdAt: new Date()
})

insertOne inserts one document. MongoDB assigns an _id when the document does not already have one. In a multi-tenant application, tenantId is not just another filterable field: it is part of the ownership boundary and should be included consistently in later reads and writes.

Many documents can be inserted in one call:

javascript
db.tasks.insertMany([
  {...},
  {...}
])

Decide whether the operation should be ordered or unordered when failures may occur. Ordered bulk insertion stops at the first error, while unordered insertion can continue with independent documents. The choice changes how you interpret partial success, so the application should inspect the result and error details rather than assuming the whole request either succeeded or failed.

Find one

Use findOne when the application needs a single matching document:

javascript
db.tasks.findOne({
  _id: ObjectId("...")
})

If no document matches, the result is null rather than an empty cursor. If the filter is expected to identify one logical record, enforce that expectation with an appropriate unique index and handle the not-found case explicitly.

Find cursor

javascript
db.tasks.find({
  completed: false
})

find returns a cursor in the shell and in drivers. A cursor represents a way to iterate through matching documents; it is not the same thing as an array containing the entire result set.

Do not assume the database sends the entire collection instantly. Drivers normally request results in batches, and the application may consume those batches incrementally. That distinction matters for memory use, latency, and the behavior of a request that matches far more documents than expected.

Equality

The simplest filter matches a field by value:

javascript
{
  priority: "high"
}

For a scalar field, this means the field has the value "high". MongoDB also applies useful array matching behavior: a scalar equality condition on an array field can match when the array contains that value. Treat the stored field type as part of the contract, because a value that is sometimes a string and sometimes an array makes both querying and indexing harder to reason about.

Comparison operators

Comparison operators are useful for ranges, thresholds, and membership tests. For example, this filter selects dates in August 2026 by including the first boundary and excluding the next month:

javascript
{
  createdAt: {
    $gte: ISODate("2026-08-01"),
    $lt: ISODate("2026-09-01")
  }
}

Common comparison operators include:

text
$eq
$ne
$gt
$gte
$lt
$lte
$in
$nin

The half-open range ($gte followed by $lt) avoids having to invent a final timestamp for the day and works well for date boundaries. $in expresses membership in a known set; it is usually preferable to building a long chain of $or equality clauses for the same field.

$ne and $nin can match broad sets, including documents where the field is absent, and are often less index-selective. Do not assume every operator query is efficient just because it uses an index. Check the actual filter, cardinality, and execution plan when performance matters.

Logical

Logical operators combine conditions explicitly:

javascript
{
  $or: [
    { priority: "high" },
    { overdue: true }
  ]
}

Common logical operators are:

text
$and
$or
$nor
$not

MongoDB implicitly ANDs different top-level fields in a filter:

javascript
{
  tenantId: ObjectId(...),
  completed: false
}

This requires both conditions to match. Use an explicit $and when the structure is clearer that way or when you need to express multiple conditions on the same field. More syntax is not automatically more correct; the important part is understanding which predicates must be true together and which are alternatives.

Element

Element operators test whether a field exists and what BSON type it has:

javascript
{
  dueDate: {
    $exists: true,
    $type: "date"
  }
}

Use $exists to distinguish an absent field. Pairing it with $type is useful when documents created by different application versions may have inconsistent shapes. An existing field with the wrong type is a different data-quality problem from a field that was never stored.

Null versus missing

This filter looks simple:

javascript
{ field: null }

It has special behavior and can match a field whose value is null as well as a document in which the field is missing, depending on the query form. That is different from treating null as an ordinary SQL-style value and assuming absence is automatically excluded.

If you need explicit existence or type semantics, use documented patterns such as $type and $exists:

javascript
{
  field: null,
  other...
}

The abbreviated filter above illustrates the surrounding filter shape from the original example; in a real query, replace other... with valid predicates. Make the intended distinction explicit when it matters. For example, $exists: true can require that a field is present, and $type can constrain the BSON type. Test null semantics with representative documents rather than assuming SQL NULL behavior.

Dot notation

Dot notation addresses a nested field without requiring the application to load and inspect the entire document first:

javascript
{
  "profile.city": "Madurai"
}

This matches a document whose nested profile.city value is "Madurai". It is convenient, but it does not remove the need to decide how missing intermediate objects and inconsistent nested types should behave in the application.

Arrays

A scalar condition can match an array containing that value:

javascript
{
  tags: "mongodb"
}

The filter matches an array containing "mongodb", not only a scalar field equal to that string. This is useful for tags and other set-like data, but remember that arrays are ordered values in the document even when the particular query is only checking membership.

To require all of several values, use $all:

javascript
{
  tags: {
    $all: [
      "node",
      "mongodb"
    ]
  }
}

To require an exact array length, use $size:

javascript
{
  tags: {
    $size: 2
  }
}

$size checks the number of elements. It does not mean that the array contains a particular pair of values or that those values occur in a particular order.

$elemMatch

Arrays of documents introduce a subtle matching problem. Suppose each items element has a sku and a quantity, and both predicates must apply to one same item:

javascript
{
  items: {
    $elemMatch: {
      sku: "A",
      quantity: {
        $gte: 2
      }
    }
  }
}

$elemMatch requires the predicates to match the same array element. Without it, separate dot-notation predicates may be satisfied by separate elements: one item could have sku: "A" while another has quantity: 2. That query would look plausible while returning the wrong orders.

This is one of the places where a small query-shape difference changes the meaning of the result. When conditions belong together as properties of one array member, make that relationship explicit with $elemMatch and test it with data that could otherwise produce a false match.

Regex

javascript
{
  title: {
    $regex: "^Mongo",
    $options: "i"
  }
}

This example looks for titles beginning with Mongo, ignoring case. Regex queries can be expensive and index-unfriendly depending on the pattern. A leading anchor may allow better use of an index in appropriate cases, but neither the syntax nor the presence of an index is a blanket performance guarantee.

User-provided regex is also a security and availability concern. It can cause:

  • regex denial of service;
  • a broad collection scan;
  • unexpected interpretation of metacharacters.

Do not pass a raw search string as a regular expression without escaping it and applying sensible length and execution limits. If the product needs full-text search, use an appropriate text or Atlas Search design rather than turning every public search box into an arbitrary database regex endpoint.

Projection

Projection controls which fields are returned. An inclusion projection can keep a list response small:

javascript
db.tasks.find(
  { completed: false },
  {
    title: 1,
    priority: 1,
    createdAt: 1
  }
)

_id is included by default unless it is explicitly excluded. That default often surprises people when they are shaping an API response, so exclude it when the response contract does not need it.

An exclusion projection removes a field while leaving other fields included:

javascript
{
  largeField: 0
}

Generally, do not mix inclusion and exclusion in one projection, except for the special _id rules. Projection reduces data transfer and the amount of data the application must decode, but it does not automatically guarantee a covered query. Coverage depends on the filter, projection, and supporting index together, and should be verified with an execution plan.

Sort

javascript
.sort({
  createdAt: -1,
  _id: -1
})

Sorting by createdAt puts newest tasks first in this example. The _id field is a deterministic tiebreaker when multiple documents share the same timestamp. A stable sort is essential for pagination; without a unique tie-breaker, documents can move between pages when equal sort keys are returned.

A sort without a supporting index can consume substantial memory and CPU. Design the compound index and query together, then confirm the behavior with explain on realistic data rather than relying on a small development collection.

Limit

javascript
.limit(25)

Always put a limit on public list APIs. A client should not be able to turn a normal list request into an accidental request for an entire tenant or collection. The limit is also part of resource protection, not merely a presentation preference.

Skip

javascript
.skip(1000)

Offset pagination is useful for small offsets and relatively stable result sets. Deep skips require MongoDB to walk past many results before returning the requested page, so latency and work can degrade as the offset grows.

For large or frequently changing lists, cursor or range pagination is usually preferable. It uses the last item from the current page to define where the next page begins, rather than repeatedly discarding an increasingly large prefix.

Count

javascript
db.tasks.countDocuments({
  completed: false
})

countDocuments provides an exact count for the supplied filter, but an exact count can be expensive for huge filters. estimatedDocumentCount() uses collection metadata to produce an estimate and has different semantics because it does not represent the same filtered operation.

Choose based on what the interface needs. A dashboard may tolerate an estimate, while a filtered export or an authorization decision may require an exact, carefully scoped count. Do not quietly substitute one for the other.

Distinct

javascript
db.tasks.distinct("priority", {
  tenantId: ObjectId(...)
})

distinct returns the different values for a field within the filter. It is useful for a small set of filter options, but it is not a replacement for a proper aggregation when you need counts, sorting, grouping, or other derived information.

Cursor iteration

The Node driver exposes a cursor that can be consumed asynchronously:

js
const cursor = collection.find(filter);

for await (const doc of cursor) {
  ...
}

This pattern streams batches rather than converting every matching document into one in-memory array. The loop still needs appropriate cancellation, error handling, and output backpressure in a real service, but it avoids the most obvious memory hazard.

Avoid this for millions of documents:

js
await cursor.toArray()

toArray() is convenient for bounded results and tests. For an unbounded or unexpectedly large result, it asks the driver to materialize all documents at once. Use a limit, a streaming iteration pattern, or a batch-oriented workflow instead.

Cursor batch size

Drivers fetch cursor results in batches. Batch-size tuning can change memory use, network round trips, and how quickly the first results become available. Defaults are usually fine; tune them only after measuring a real workload and understanding whether the bottleneck is network transfer, server work, or consumer speed.

Update one

An update filter should identify both the document and the ownership scope when the collection is multi-tenant:

javascript
db.tasks.updateOne(
  {
    _id: ObjectId("..."),
    tenantId: ObjectId("...")
  },
  {
    $set: {
      completed: true,
      updatedAt: new Date()
    },
    $inc: {
      version: 1
    }
  }
)

The update operators change selected fields without replacing the document. An update to one document is atomic: other clients do not observe a half-applied combination of these field changes. Atomicity at the document level does not mean that a multi-document workflow is automatically transactional.

Update operators

Common field update operators include:

text
$set
$unset
$inc
$mul
$min
$max
$currentDate
$rename

Common array operators include:

text
$push
$addToSet
$pull
$pop

Use update operators instead of a read-modify-write cycle where possible. The server can apply the intended change to the current document, reducing the window in which another client can overwrite an update.

Lost update

This pattern is vulnerable to two clients changing the same document:

text
read document
modify in app
replace

Both clients can read the same old value, make different changes, and then let the later replacement overwrite the earlier one. The application may report success even though one user's change disappeared.

For an additive counter, use an atomic operator such as:

javascript
$inc

For an edit that should proceed only when the document is still at the version the client read, include a version predicate:

javascript
{
  _id,
  version: expectedVersion
}

Then update the patch and increment the version together:

javascript
{
  $set: patch,
  $inc: { version: 1 }
}

If the matched count is zero, the expected version was not found. Treat that as a conflict or not-found condition according to the endpoint contract; do not blindly retry the same stale replacement.

$push

javascript
{
  $push: {
    tags: "node"
  }
}

$push appends a value and allows duplicates. $addToSet avoids adding a duplicate exact value:

text
$addToSet

Neither operator solves the growth problem for an unbounded array. Set a model-level bound or move the growing data into a separate collection when the number of entries can continue to increase.

Push modifiers

MongoDB supports modifiers that control a pushed batch:

text
$each
$slice
$sort
$position

Together they can maintain a bounded recent-items array. This example inserts the new event at the front and keeps only the first twenty entries:

javascript
{
  $push: {
    recentEvents: {
      $each: [newEvent],
      $position: 0,
      $slice: 20
    }
  }
}

This is a useful subset pattern for data that is intentionally embedded and bounded. It is not a general substitute for an event collection: if events need independent querying, retention, or unbounded history, the data model should reflect that.

Array filters

Array filters update selected elements rather than every member of an array:

javascript
db.orders.updateOne(
  { _id: orderId },
  {
    $set: {
      "items.$[item].status": "ready"
    }
  },
  {
    arrayFilters: [
      {
        "item.sku": "A"
      }
    ]
  }
)

The identifier item connects the placeholder in the update path to its condition in arrayFilters. Validate identifiers and conditions before allowing them to influence a query or update. Complex array updates can be a sign that an embedded model has become too large or that a separately stored relationship would be easier to maintain.

Replace

javascript
replaceOne(filter, replacement)

replaceOne replaces the document content, except for the immutable _id. It is therefore easy to drop fields accidentally when replacement was built from an incomplete request body. Use PATCH-style update operators for a partial update unless full-document replacement is deliberate and the replacement shape is validated.

Delete

Scope destructive operations by tenant and authorization boundary:

javascript
db.tasks.deleteOne({
  _id,
  tenantId
})

The identifier alone may be globally unique, but that does not remove the value of including the ownership predicate. It makes the operation enforce the same boundary as the read path and reduces the chance that an authorization bug becomes a cross-tenant deletion.

For multiple documents:

javascript
deleteMany({
  archived: true,
  archivedAt: {
    $lt: cutoff
  }
})

Before a destructive bulk operation:

  1. run find with the same filter;
  2. count the matches;
  3. inspect a representative sample;
  4. take the required backup or use the approved transaction/change process;
  5. execute the deletion.

Previewing with the identical filter catches wrong fields, wrong dates, and missing tenant scope before data is removed. Production processes may add approval, logging, or a dry-run report, but the basic discipline is the same.

Upsert

An upsert updates an existing match or creates a document when there is no match:

javascript
updateOne(
  { externalId },
  {
    $set: {...},
    $setOnInsert: {
      createdAt: new Date()
    }
  },
  {
    upsert: true
  }
)

$setOnInsert applies only when MongoDB creates the document. Use a unique index to enforce uniqueness under concurrency. A filter alone without a unique index can race: two clients can both observe no match and both attempt an insert.

findOneAndUpdate

findOneAndUpdate returns a document according to the operation's options. It is useful when the caller needs the result of an atomic claim or update rather than issuing a separate read after the write.

For a job-claiming workflow, the predicate and supporting index must be robust, and the code must deliberately choose whether it receives the document before or after the update. Return-after semantics are part of correctness here, not just response formatting. A claim also needs a clear lease or status rule so that two workers cannot both treat the same job as available.

Bulk write

bulkWrite groups several write models into one database call:

javascript
db.tasks.bulkWrite([
  {
    updateOne: {
      filter: { _id: id1 },
      update: { $set: { completed: true } }
    }
  },
  {
    deleteOne: {
      filter: { _id: id2 }
    }
  }
])

This is useful for many operations because it can reduce round trips. It is not the same as an all-or-nothing transaction. Ordered execution is the default and stops after the first error; unordered execution can continue with independent operations.

Always understand partial success. Some operations may already have applied when a later operation fails, so the response and error information must be recorded and reconciled rather than reported as a simple binary result.

Retryable writes

MongoDB supports retryable writes for selected operations and configurations. A driver may retry a transient failure safely when the operation has retryable semantics and the deployment and driver settings support them.

Do not build manual retries around non-idempotent operations without understanding the driver and server behavior. A retry can duplicate an effect if the first request reached the server but the client did not receive the response. The broader consistency lesson goes deeper than this CRUD overview: retry policy, idempotency, write concern, and transaction behavior must be designed together.

Query injection

Passing a client object directly as a filter gives the client control over query structure:

js
const filter = req.body.filter;

collection.find(filter);

An attacker may supply operators such as:

json
{
  "$where": "...",
  "$ne": ...
}

The exact impact depends on server and API capabilities, but arbitrary client-controlled operators can bypass intended constraints, broaden a scan, or expose a dangerous execution path. Treat request data as untrusted and construct the filter from validated values:

js
const filter = {
  tenantId: auth.tenantId,
};

if (input.status) {
  filter.status = input.status;
}

Validate types, allowed values, string lengths, and ownership scope. Do not allow arbitrary MongoDB operators from a public client unless the endpoint intentionally exposes a constrained and safe query DSL. Escaping a regex is only one part of this boundary; it does not make an arbitrary filter object safe.

Cursor pagination

Cursor pagination needs a stable ordering. This example sorts newest records first and uses _id as the tie-breaker:

javascript
{
  createdAt: -1,
  _id: -1
}

After returning a cursor containing (createdAt, _id), the next-page filter selects records that sort after that pair in descending order:

javascript
{
  tenantId,
  $or: [
    {
      createdAt: {
        $lt: cursor.createdAt
      }
    },
    {
      createdAt: cursor.createdAt,
      _id: {
        $lt: cursor.id
      }
    }
  ]
}

The first branch selects older timestamps. The second selects the next _id values when timestamps are equal. The filter must retain the tenant scope, and the query requires a supporting compound index that matches the ownership, ordering, and access pattern.

Do not use the timestamp portion of _id alone when the actual business order is another field. An ObjectId's embedded timestamp is not a substitute for the field that defines the product's ordering rules.

Common mistakes

The mistakes below tend to pass a happy-path demo and fail under real input or scale:

  • accepting a raw client filter;
  • building a regex directly from user input;
  • using deep skip at scale;
  • converting an unbounded cursor with toArray;
  • replacing a document and losing fields;
  • upserting without a unique index;
  • updating by _id without tenant scope;
  • using read-modify-write and losing a concurrent update;
  • allowing an unbounded push into an array;
  • running a bulk delete without previewing it;
  • storing inconsistent field types;
  • being surprised by a broad $ne scan.

When debugging one of these failures, inspect the boundary where the assumption was introduced: request validation, filter construction, query plan, cursor consumption, update result, or authorization scope. The database result alone is often not enough to explain why the application behaved incorrectly.

Exercises

  1. Insert, find, update, and delete tasks.
  2. Query nested and array fields.
  3. Demonstrate $elemMatch.
  4. Project a small response shape.
  5. Build cursor iteration.
  6. Compare skip and range pagination.
  7. Implement a versioned update conflict.
  8. Maintain a bounded recent-events array.
  9. Build an upsert with a unique-index plan.
  10. Sanitize a public filter builder.
  11. Use bulkWrite and inspect partial errors.

Work through the exercises with data that includes missing fields, null, duplicate array values, equal timestamps, and at least one concurrent-update scenario. Those cases are where the query semantics and safety decisions become visible.

Mastery checklist

Explain:

  • CRUD;
  • operators;
  • null/missing;
  • arrays/elemMatch;
  • projection;
  • cursor;
  • skip/limit;
  • atomic updates;
  • upsert;
  • bulkWrite;
  • retryable writes concept;
  • query injection;
  • cursor pagination.

You should be able to explain not only the syntax, but also the boundary conditions: what a missing field does, why $elemMatch changes an array query, why an update can be atomic without solving a multi-document workflow, and why a public filter must be built from an allowlist of validated values.

Official references

Reader page: /mongodb/lesson/136/mongodb-crud-and-query-language-filters-operators-arrays-projection-cursors-bulk-writes-and-safe-updates