Automattic/mongoose · error · MongooseError

Invalid update pipeline operator: "${op}"

Error message

Invalid update pipeline operator: "${op}"

What it means

Mongoose whitelists pipeline stages in update pipelines: only $unset, $project, $addFields, $set, $replaceRoot and $replaceWith are allowed. Any other top-level key in any stage of the array throws MongooseError 'Invalid update pipeline operator: "<op>"'. This is stricter than the MongoDB server, which also permits stages like $match and $addFields variants in updates — Mongoose rejects them client-side.

Source

Thrown at lib/helpers/query/castUpdate.js:202

  if (op === '$project') {
    if (val == null || typeof val !== 'object') {
      throw new MongooseError('Invalid $project in pipeline, must be an object');
    }
    return val;
  }
  if (op === '$addFields' || op === '$set') {
    if (val == null || typeof val !== 'object') {
      throw new MongooseError('Invalid ' + op + ' in pipeline, must be an object');
    }
    return val;
  } else if (op === '$replaceRoot' || op === '$replaceWith') {
    if (val == null || typeof val !== 'object') {
      throw new MongooseError('Invalid ' + op + ' in pipeline, must be an object');
    }
    return val;
  }

  throw new MongooseError('Invalid update pipeline operator: "' + op + '"');
}

/**
 * Walk each path of obj and cast its values
 * according to its schema.
 *
 * @param {Schema} schema
 * @param {object} obj part of a query
 * @param {string} op the atomic operator ($pull, $set, etc)
 * @param {object} [options]
 * @param {boolean|'throw'} [options.strict]
 * @param {Query} context
 * @param {object} filter
 * @param {string} pref path prefix (internal only)
 * @return {Bool} true if this path has keys to update
 * @api private
 */

View on GitHub (pinned to 49cdab0136)

Solutions

  1. Move filtering into the update's first argument (the filter), keep only allowed stages in the array
  2. Use Model.aggregate() when you need $group/$match/other stages — then merge/write with $merge or $out if needed
  3. Check each stage key against the allowlist ($unset, $project, $addFields, $set, $replaceRoot, $replaceWith) before sending

Example fix

// before
User.updateMany({}, [{ $match: { status: 'active' } }, { $set: { archived: true } }]);

// after: filter goes in the query, not the pipeline
User.updateMany({ status: 'active' }, [{ $set: { archived: true } }]);
Defensive patterns

Strategy: validation

Validate before calling

const ALLOWED = new Set(['$unset','$project','$addFields','$set','$replaceRoot','$replaceWith']);
for (const stage of pipeline) {
  for (const op of Object.keys(stage)) {
    if (!ALLOWED.has(op)) throw new Error(`${op} not allowed in a Mongoose update pipeline`);
  }
}

Type guard

const UPDATE_PIPELINE_STAGES = ['$unset','$project','$addFields','$set','$replaceRoot','$replaceWith'] as const;
type UpdatePipelineStage = { [K in typeof UPDATE_PIPELINE_STAGES[number]]?: Record<string, unknown> };

Try / catch

try { await Model.updateOne(f, pipeline); } catch (err) { if (/Invalid update pipeline operator/.test(err.message)) { /* move $match-style logic into the filter, use aggregate() for the rest */ } throw err; }

Prevention

When it happens

Trigger: Model.updateOne({}, [{ $match: { status: 'active' } }, { $set: { archived: true } }]); [{ $group: {...} }]; any stage key outside the allowlist, including typos like '$Set' or '$unsetField'.

Common situations: Pasting an aggregation pipeline into updateOne expecting it to work; trying to filter with $match inside an update pipeline instead of the query filter; assuming all aggregation stages are supported in update pipelines because MongoDB accepts some Mongoose does not.

Related errors


AI-assisted analysis of Automattic/mongoose@49cdab0136 (2026-08-21). Data as JSON: /api/errors/3c994b64470cd755. Report an issue: GitHub.