何が起きたのか
$push や $addToSet を使って配列に値を追加しようとしたところ、MongoDBが処理を止めてしまいました:
MongoServerError: The path 'tags' must be an array in the document, but is of type string
フィールドはドキュメントに存在しているのですが、配列ではなく文字列が格納されています。$push と $addToSet は実際の配列にしか機能しません。データを黙って破損させる代わりに、MongoDBは操作全体を拒否します。
30秒で再現する
mongosh を開いて次を実行してください:
// 'tags' が単純な文字列のドキュメントを挿入
db.articles.insertOne({ _id: 1, title: "Hello", tags: "mongodb" })
// 別のタグをプッシュしようとする
db.articles.updateOne({ _id: 1 }, { $push: { tags: "nodejs" } })
// MongoServerError: The path 'tags' must be an array...
フィールドは ["mongodb"] ではなく文字列です。これが問題の本質です。
ステップ1:影響を受けるドキュメントのフィールド型を確認する
何かを変更する前に、実際に格納されているものを確認します:
db.articles.findOne({ _id: 1 }, { tags: 1 })
// { _id: 1, tags: "mongodb" } ← 配列ではなく文字列
影響を受けるドキュメントが何件あるか分からない場合は、tags が配列でないすべてのドキュメントを検索します:
db.articles.find({
tags: { $exists: true },
$expr: { $ne: [{ $type: "$tags" }, "array"] }
})
変更を加える前にこれを実行してください。まず全体像を把握することが重要です。
ステップ2:型が間違っているドキュメントを修正する
ケースA — フィールドに単一の文字列値が入っている場合
既存の文字列を配列でラップしてから、新しい項目を追加します:
// 特定の既知ドキュメントの場合
const doc = db.articles.findOne({ _id: 1 })
const existingTag = doc.tags // "mongodb"
db.articles.updateOne(
{ _id: 1 },
{ $set: { tags: [existingTag, "nodejs"] } }
)
// 確認
db.articles.findOne({ _id: 1 }, { tags: 1 })
// { _id: 1, tags: [ "mongodb", "nodejs" ] }
ケースB — 影響を受けるドキュメントを一括修正する場合
不正なドキュメントが数百件ある場合は、集計パイプラインによるアップデートを使って、すべてのスカラー値を配列でラップします。アプリ側でのループ処理不要で、一度の操作で完結します:
db.articles.updateMany(
{
tags: { $exists: true },
$expr: { $ne: [{ $type: "$tags" }, "array"] }
},
[
{ $set: { tags: ["$tags"] } } // パイプラインアップデート — 値を [] でラップする
]
)
{ $set: ... } を囲む角括弧はタイポではありません。これは集計パイプラインアップデート構文(MongoDB 4.2以降)です。通常のアップデート式ではできない、$set の右辺でドキュメント自身のフィールド値("$tags")を参照できます。
ケースC — フィールドがnull、存在しない、またはその他の非文字列型の場合
フィールドが null だったり、そもそも存在しないこともあります。まず空の配列にリセットしてからプッシュします:
// tagsがまだ配列でないすべてのドキュメントに [] をセット
db.articles.updateMany(
{ $expr: { $ne: [{ $type: "$tags" }, "array"] } },
{ $set: { tags: [] } }
)
// これで $push が正常に動作する
db.articles.updateMany({}, { $push: { tags: "mongodb" } })
ステップ3:再発を防ぐ
オプション1 — JSONスキーマバリデーション(推奨)
スキーマルールを追加して、tags に配列以外の値を書き込もうとした場合にMongoDBが拒否するようにします:
db.runCommand({
collMod: "articles",
validator: {
$jsonSchema: {
bsonType: "object",
properties: {
tags: {
bsonType: "array",
items: { bsonType: "string" },
description: "tags must be an array of strings"
}
}
}
},
validationLevel: "moderate" // "strict" は既存の不正ドキュメントも拒否する
})
これ以降、tags: ["mongodb"] ではなく tags: "mongodb" を送信するインサートは、明確なバリデーションエラーで即座に失敗します。本番環境で $push が突然エラーを出し始めてから何時間も後に気づくのではなく、書き込み時点で捕捉できます。
オプション2 — Mongooseのスキーマ型強制
Mongooseを使用している場合は、スキーマでフィールドを明示的に配列として宣言します:
const articleSchema = new mongoose.Schema({
title: String,
tags: { type: [String], default: [] } // 常に配列
})
const Article = mongoose.model("Article", articleSchema)
Mongooseは保存時に型を強制変換します。また、doc.tags.push("nodejs") のようなMongooseドキュメントメソッドも最初から正しく機能します。生のMongoDBドライバーの呼び出しは不要です。
オプション3 — $each ガードを使った $addToSet の活用
データが正常になったら、タグフィールドには $push より $addToSet を優先して使用してください。配列にすでに存在する値は自動的にスキップされます:
db.articles.updateOne(
{ _id: 1 },
{ $addToSet: { tags: { $each: ["nodejs", "mongodb"] } } }
)
修正が正しく適用されたか確認する
// 1. 特定のドキュメントを確認
db.articles.findOne({ _id: 1 }, { tags: 1 })
// 期待値: { _id: 1, tags: [ "mongodb", "nodejs" ] }
// 2. 配列でないtagsフィールドがもう存在しないことを確認
db.articles.countDocuments({
tags: { $exists: true },
$expr: { $ne: [{ $type: "$tags" }, "array"] }
})
// 期待値: 0
// 3. 元の $push を再実行 — 今度は正常に動作するはず
db.articles.updateOne({ _id: 1 }, { $push: { tags: "express" } })
// 期待値: { acknowledged: true, matchedCount: 1, modifiedCount: 1 }
なぜこの問題が繰り返されるのか(そして防ぐ方法)
- 根本原因は型の不一致です。
$pushと$addToSetは実際の配列を必要とします。"mongodb"のような単一値の文字列は対象外であり、データを黙って破損させるのではなく、操作全体が失敗します。 - マイグレーションが主な原因です。 古いバージョンのアプリは1つのタグを単純な文字列として保存していました。後のスキーマ変更で配列による複数タグのサポートが導入されたものの、古いドキュメントはバックフィルされませんでした。何千ものレコードが誤った型を持ったまま残り、
$pushがそれに当たるまで誰も気づきません。 - 集計パイプラインアップデートが最もクリーンな一括修正方法です。
[{ $set: { field: ["$field"] } }]というパターンで、アプリケーション側にドキュメントを取り込むことなく、一度の書き込みでスカラー値を配列にラップできます。 - スキーマバリデーションはすぐに元が取れます。 MongoDBはデフォルトで何でも書き込みます。
collModのバリデータールールを1つ追加するだけで状況が変わります。型ミスが数週間後に分かりにくいランタイムエラーとして表面化するのではなく、書き込み時点で捕捉されます。

