Dealing with Mongoose subdocument arrays

July 29, 2026

Kazi Ehsan Aziz

Mongoose makes subdocument arrays feel like ordinary JavaScript arrays. You load the parent document, and then:

  1. find() the element you want from the subdocument array and update a field there, then call parent.save(). It works efficiently, as long as the array is small.
  2. Remove a subdocument from the array with filter() and reassign its result to the array, then call parent.save(). It works correctly until race-conditions kick in.

This article works through four rules I ended up with for a collaborative-writing backend, where a single document (named "Postboard") holds an array of subdocuments (named "turns") and each turn's text field can be 30,000 characters. Let's look at a simplified version of the schema for context.


The document & subdocument schema

postboard.model.ts
const turnSchema = new mongoose.Schema(
  {
    text: { type: String, maxlength: 30000 },
    authorId: { type: String, ref: "User" },
  },
  { timestamps: true },
);

const postboardSchema = new mongoose.Schema(
  {
    // "Post" is another collection, we can ignore it for this discussion.
    postId: { type: String, ref: "Post", unique: true, required: true },
    turns: { type: [turnSchema], default: [], required: true },
  },
  { timestamps: true },
);

The idea is that several users may be updating a Postboard, and different users could be working on different Turn subdocuments. Here is the code that a "update a turn" endpoint wants to be:

const updateTurn = async (
  postId: string,
  turnId: string,
  text: string,
  userId: string,
) => {
  const postboard = await Postboard.findOne({ postId });
  const turn = postboard.turns.find((t) => t.id === turnId);
  if (turn.authorId !== userId) return forbidden();
  turn.text = text;
  await postboard.save();
};

The code is correct. But it can be more efficient.

  1. The read pulls the entire turns array over the wire. A forty-turn board is more than a megabyte of text fetched to change one field.
  2. The ownership check runs in application memory, it can be done on the database in the filter stage.

'Find' Rule: $elemMatch

$elemMatch in the filter, and $elemMatch again in the projection:

const postboard = await Postboard.findOne(
  { postId, turns: { $elemMatch: { _id: turnId } } }, // filter
  { turns: { $elemMatch: { _id: turnId } } }, // projection
);
const turn = postboard?.turns[0];

The projection is the part that saves the megabyte: projection $elemMatch returns at most one element of the array, so the document that comes back has a turns array of length 0 or 1 and the element you want is always at index 0. Nothing else in the array crosses the network.

There was an ownership check line that ran on the query result.

if (turn.authorId !== userId) return forbidden();

If we wanted to incorporate that into the query's filter stage, without $elemMatch, the obvious flat filter would be:

await Postboard.findOne({
  postId,
  "turns._id": turnId,
  "turns.authorId": userId,
});

But this is wrong. MongoDB evaluates those two conditions independently across the subdocument array. The document matches when some turn has that _id and some turn has that authorId — not necessarily the same turn. This is why the ownership check was a separate line after the query result. However, with $elemMatch, we can keep it in the query without any harm:

const postboard = await Postboard.findOne(
  {
    postId,
    turns: {
      // works like a boolean AND operator on subdocuments
      $elemMatch: { _id: turnId, authorId: userId },
    },
  },
  { turns: { $elemMatch: { _id: turnId } } }, // projection
);
const turn = postboard?.turns[0];

'Update' Rule: 'updateOne', 'findOneAndUpdate'

After getting the correct element with $elemMatch, the obvious next thought is: mutate the subdocument, save() on the parent, and let Mongoose update the timestamps.

const turn = postboard?.turns[0];
turn.text = text;
await postboard.save();

That throws:

DivergentArrayError: For your own good, using `document.save()` to update an array which was
selected using an $elemMatch projection OR populated using skip, limit, query conditions, or
exclusion of the _id field when the operation results in a $pop or $set of the entire array is
not supported. The following path(s) would have been modified unsafely:
  turns.0.updatedAt
Use Model.updateOne() to update these arrays instead.

Mongoose knows the array it loaded is partial, so an index-based path like turns.0.text would be nonsense against the real document. It handles this by rewriting the path to turns.$ ($ is known as the positional operator) — but this case is only successfully handled by Mongoose if there was just one dirty path, i.e. if only text was updated.

Since we set timestamps: true on the subdocument schema, changing one field and calling .save() dirties two paths: turns.$.text and turns.$.updatedAt. Hence, the error. The correct way:

const now = new Date();

const result = await Postboard.updateOne(
  { postId, turns: { $elemMatch: { _id: turnId, authorId: userId } } },
  {
    $set: {
      "turns.$.text": text,
      "turns.$.updatedAt": now,
    },
  },
  { runValidators: true },
);

A bonus with this approach: the turn is located and rewritten on the database server in one atomic operation.


Two details that are easy to miss:


A miss now collapses two different failures — "no such turn (404)" and "not your turn (403)" — into matchedCount: 0. Pay for the distinction only on the failure path, with one projected read:

if (result.matchedCount === 0) {
  const probe = await Postboard.findOne(
    { postId, turns: { $elemMatch: { _id: turnId } } },
    { turns: { $elemMatch: { _id: turnId } } },
  ).lean();

  return probe?.turns?.length ? forbidden() : notFound();
}

Getting the updated element back in one round trip

If you need to return the updated result, use findOneAndUpdate and project on the way out. new: true gives you the post-write state, and the projection option applies to the returned document — so you get the one element, not the array:

const updated = await Postboard.findOneAndUpdate(
  someFilters,
  { $set: someUpdates },
  {
    new: true,
    projection: { turns: { $elemMatch: { _id: turnId } } },
    runValidators: true,
  },
);

const turn = updated?.turns[0];

One atomic operation does the match, the write, and the read-back.


'Append' Rule: $push

Appending to a top-level array needs no positional operator, but it has its own problem: after the $push, how do you identify the element you just pushed so you can return it? Hence, construct an ObjectId yourself:

post.service.ts
const turnId = new Types.ObjectId();

const updatedPostboard = await Postboard.findOneAndUpdate(
  { postId },
  { $push: { turns: { _id: turnId, text, authorId: userId } } },
  {
    new: true,
    projection: { turns: { $elemMatch: { _id: turnId } } },
    runValidators: true,
  },
);

const addedTurn = updatedPostboard?.turns[0];

$push is concurrency safe. Multiple users can be appending to the same array without any data inconsistency.

$push is also the one update operator where Mongoose still does work for you: it casts the pushed object through the subdocument schema, so defaults and subdocument timestamps are applied. The projected result comes back with createdAt and updatedAt already stamped.


'Delete' Rule: $pull

And finally, the element deletion from a subdocument array.

post.service.ts
const result = await Postboard.updateOne(
  { postId, turns: { $elemMatch: { _id: turnId, authorId: userId } } },
  { $pull: { turns: { _id: turnId } } },
);

$pull is concurrency safe. Multiple users can be removing from the same array without any data inconsistency.