MongoDBの更新エラー修正:更新適用後に(不変)フィールド「_id」が変更されたことが検出されました

intermediate🍃 MongoDB2026-05-06| MongoDB 4.0+、Node.js/Python/Goドライバー、Mongoose ODM、Linux/Docker環境

Error Message

After applying the update, the (immutable) field '_id' was found to have been altered
#mongodb#update#immutable#_id

問題の概要Node.js APIの更新エンドポイントを構築していたとき、MongoDBが突然すべての書き込みを拒否し始めました。ペイロードは問題なく見えました。クエリも問題なく見えました。しかしエラーは容赦なく続きました:

WriteError: After applying the update, the (immutable) field '_id' was found to have been altered

MongoDBでは、_idはドキュメントの永続的な一意識別子です。作成後に変更することは決してできません — 同じ値であっても不可です。厄介なのは、そもそも触るつもりがなかった場合がほとんどだという点です。MongoDBは$setペイロードにそのフィールドを見つけると、操作全体を拒否します。

よくある発生パターンほとんどの場合、これは意図的なものではありません。以下のいずれかのパターンで紛れ込みます:

  • **req.bodyをそのまま展開する:**クライアントが_idを含むフルオブジェクトを送信し、フィルタリングせずに$setにそのまま渡してしまう。- **汎用的な更新ヘルパー:**共通のユーティリティ関数がフルドキュメントを受け取り、メタデータフィールドを除去せずにMongoDBに書き戻す。- **Mongoose + Object.assign():**ドキュメントを取得した後、.save()を呼び出す前にObject.assign(doc, req.body)を使用する。req.body_idが含まれていると、Mongooseの内部状態が破損する。## 修正方法### 1. _idを分割代入で除外する(最善の方法)ES6の分割代入を使えば1行で解決できます。_idを単独で取り出し、残りを更新ペイロードに使います:
// 問題あり — $setに_idが渡される
const updateData = req.body;
await db.collection('users').updateOne(
  { _id: userId },
  { $set: updateData }
);

// 修正済み — _idが$setに渡されない
const { _id, ...payload } = req.body;
await db.collection('users').updateOne(
  { _id: userId },
  { $set: payload }
);

これはシンプルで読みやすく、ES2018以降のすべてのNode.jsバージョンで動作します。

2. キーを手動で削除する分割代入が適さない場合 — たとえば既存オブジェクトを変更するミドルウェアの中など — キーを直接削除する方法もあります:

const updateData = { ...req.body };
delete updateData._id;

db.collection('products').updateOne({ _id: productId }, { $set: updateData });

必ず最初に新しいオブジェクトにスプレッドしてください({ ...req.body })。req.body自体を変更するのは、リクエストのライフサイクルの他の部分で微妙なバグを引き起こす悪い習慣です。

3. Mongoose:save()の代わりにfindByIdAndUpdateを使うMongooseでは、Object.assign()を完全に避けるのが最も安全な方法です:

// 危険 — req.body._idがドキュメントの内部トラッカーを破損させる可能性がある
const user = await User.findById(id);
Object.assign(user, req.body);
await user.save();

// 安全 — Mongooseがここで_idを正しく処理する
const { _id, ...updateFields } = req.body;
await User.findByIdAndUpdate(id, updateFields, { new: true });

{ new: true }オプションは元のドキュメントではなく更新済みのドキュメントを返します — すぐにクライアントに結果を返す必要がある場合に便利です。

修正が機能したか確認する方法- ログを確認する:WriteErrorが消えているはずです。まだ表示される場合は、別のコードパスが_idを渡しています。- **ペイロードをログ出力する:**データベース呼び出しの直前にconsole.log(payload)を追加し、オブジェクトから_idが除外されていることを確認してください。- **ドキュメントを確認する:**MongoDB CompassまたはAtlasを開き、ドキュメントが正しく更新されているか確認します — 値が変更され、_idは変わっていないことを確認してください。## 再発を防ぐために- フィールドのホワイトリストを使う:_idをブロックする代わりに、受け入れるフィールドを明示的に指定します — たとえばconst { name, email, role } = req.bodyのように。これにより同時にマスアサインメントの脆弱性も防げます。- Lodashユーザー向け:_.omit(data, ['_id', '__v'])で、MongoDBのIDとMongooseのバージョンキーを1回の呼び出しで両方除去できます。- API設計の習慣:_idはリクエストボディではなく、URLパス(PUT /api/users/:id)に含めるようにしましょう。フロントエンドが送信すべきではなく、バックエンドも受け入れるべきではありません。

Related Error Notes