Mongooseとは?Node.js用MongoDB ODMの使い方とv9の変更点
Mongooseは、MongoDBをNode.jsからスキーマ付きで扱うためのODM(Object Data Modeling)ライブラリです。同じ綴りの自転車ブランドや動物と紛らわしいのですが、この記事で扱うのはnpmパッケージ mongoose のほうです。2026年7月30日時点の最新版は9.8.1で、Node.js 20.19.0以上を要求します。ここでは接続とモデル定義の最小構成に加え、required・unique・selectが実際にはどう動くか、findOneAndUpdateが既定で何を返すかまで、公式ドキュメントの記述に沿って整理します。
まとめ
- MongooseはMongoDBのスキーマレスなコレクションに、アプリ側でスキーマ・型変換・バリデーションを被せるODMです。
- 最新は9.8.1、必須ランタイムはNode.js 20.19.0以上、内部で使うMongoDB Nodeドライバは7.5系です。
unique: trueはバリデータではなく、ユニークインデックスを作る省略記法にすぎません。インデックスが張られる前は重複が保存されます。- 更新系のバリデーションは既定で動きません。
runValidators: trueを明示し、thisがドキュメントではなくクエリになる点に注意します。 findOneAndUpdateは既定で更新前のドキュメントを返します。更新後が欲しいときはreturnDocument: 'after'を指定します(new: trueは非推奨)。
以下の記述は、公式ドキュメントの原文とmongoose 9.8.1が実際に返すエラーメッセージに基づいています。
Mongooseの位置づけと同名プロダクトの切り分け
ODMとしての役割とネイティブドライバとの違い
MongoDBはドキュメント単位で構造が異なっていても保存できるデータベースです。柔軟な反面、アプリ側で「このコレクションのドキュメントは必ずこの形」という保証を持ちたい場面が出てきます。Mongooseが提供するのは、その保証をスキーマとして宣言する仕組みと、保存前の型キャスト・バリデーション・仮想プロパティ・ミドルウェア(フック)です。MongoDB本体のデータ構造そのものについてはMongoDBとは?ドキュメント指向データベースの構造・ドキュメント・使い方入門で整理しています。
公式のMongoDB Nodeドライバは、この層を持ちません。役割はBSONとJavaScriptオブジェクトの相互変換、そしてコマンド送信だけです。Mongooseはそのドライバをラップしており、Mongoose 9は7.5系、Mongoose 8は6.x系のドライバを内部で使います。つまりMongooseを入れてもドライバを捨てるわけではなく、その上にスキーマ層が乗る構成になります。
自転車ブランド・組み込みサーバーとの区別
「mongoose」で検索すると、BMXやマウンテンバイクのブランドMONGOOSE、マングース(動物)、Cesanta製の組み込みWebサーバーMongooseが混ざって出てきます。このうちnpmレジストリ上のmongooseパッケージが指すのは、MongoDB用ODMだけです。技術文書を探すときは「mongoose mongodb」「mongoose schema」のように文脈語を足すと、npmと公式ドキュメントが上位に入れ替わります。
Mongoose 9の動作要件と破壊的変更
必須ランタイムとドライバ構成
Mongoose 9の移行ガイドは「Mongoose 9 requires Node.js 20.19.0 or higher.」と明記しています。npmレジストリ上の9.8.1もengines.nodeに>=20.19.0を持ち、依存するmongodbは~7.5です。境界は「20系かどうか」ではなくパッチ番号まで含みます。Node 18系はもちろん、20.18.xで止まっている環境もMongoose 9の対象外です。ランタイム側の更新可否はNode.js 26の新機能・変更点とLTSスケジュール総まとめでLTSの時期を確認したうえで判断してください。
8から9で書き換えが必要な箇所
| 対象 | Mongoose 9での挙動 | 対応 |
|---|---|---|
| preミドルウェア | next()を受け取らない | async関数へ書き換え |
| 更新パイプライン | 既定で不許可 | updatePipeline: true を指定 |
| indexのbackground | オプション削除 | 指定を除去 |
| isValidObjectId(数値) | false を返す | 数値を渡す経路を修正 |
| doValidate() | Promiseを返す | await で受ける |
| FilterQuery(TS) | QueryFilter へ改名 | 型名を置換 |
| Node.js | 20.19.0 以上が必須 | ランタイムを更新 |
影響が大きいのはpreミドルウェアです。Mongoose 9では第1引数のnextが渡されないため、8までのfunction (next) { ... next(); }という書き方はTypeError: next is not a functionで失敗し、save()やvalidate()がrejectされます。次のようにasync関数へ寄せてください(userSchemaは後述のスキーマ定義を指します)。
userSchema.pre('save', async function () {
this.email = this.email.trim().toLowerCase();
});
カスタムメソッドやstaticsに付けたフックもコールバック形式が廃止されました。移行に着手する前に、既存コードのpreとpostを機械的に洗い出しておくと取りこぼしが減ります。
TypeScriptで参照する型名の変更
TypeScriptから使っている場合、影響が出るのは型名です。9.8.1に同梱される型定義からFilterQueryは消え、QueryFilterに置き換わりました。sanitizeFilterのシグネチャもQueryFilterを受け取る形になっています。クエリ条件を自前のユーティリティで組み立てている実装では、まずこの型名を一括置換してからコンパイルを通すのが最短です。
ドキュメントの型としてはHydratedDocument、スキーマから生の型を導出するInferSchemaTypeが引き続き使えます。スキーマ定義を単一の情報源にしてInferSchemaTypeで型を導く書き方にしておくと、フィールド追加のたびにインターフェースを二重管理せずに済みます。
接続とモデル定義の最小構成
接続文字列とconnectの書き方
接続はアプリ起動時に一度だけ行います。mongoose.connectはPromiseを返すので、awaitして失敗を検知できる形にしておきます。Mongoose 9はNode.js 20.19.0以上が前提なので、トップレベルawaitが使えるESMで書くのが素直です。
import mongoose from 'mongoose';
await mongoose.connect('mongodb://127.0.0.1:27017/appdb');
CommonJSのrequireで書く場合、トップレベルawaitはSyntaxErrorになります。async function main() { ... }で包むか、拡張子を.mjsにして上のESM形式に揃えてください。接続文字列の末尾がデータベース名で、認証やレプリカセットを使う場合はmongodb+srv形式のURIをそのまま渡します。
古い記事によく出てくるuseNewUrlParserとuseUnifiedTopologyは、現行版では単に不要なのではなく、渡すとMongoParseError: options usenewurlparser, useunifiedtopology are not supportedで接続そのものが失敗します。コピーしてきたコードからは必ず削除してください。TypeScriptではConnectOptionsに存在しないため、コンパイル時点で気付けます。
本番のURIは環境変数から読み、コードに直書きしないでください。MongoDBはネットワーク到達性の設計を誤ると情報漏えいに直結し、MongoDBにおける情報漏えい脆弱性(CVE-2025-14847、別名MongoBleed)の発覚と概要のような事例も報告されています。
スキーマ定義とモデル生成
スキーマはフィールドの型と制約の宣言、モデルはそのスキーマからコレクションを操作するためのクラスです。モデル名を単数形で渡すと、Mongooseが小文字の複数形コレクション名を自動で決めます(Userならusers)。
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true, select: false },
age: { type: Number, min: 0 }
}, { timestamps: true });
const User = mongoose.model('User', userSchema);
strictは既定でtrueなので、スキーマに無いキーを渡しても保存されません。timestampsを有効にするとcreatedAtとupdatedAtが自動で付きます。ここで宣言したrequired・unique・selectは、名前から受ける印象と実挙動が大きくずれます。
フィールドオプションの実挙動
requiredが効く範囲
requiredは組み込みバリデータのひとつで、文字列なら空文字も未設定として弾きます。ただし効く経路はsave()とcreate()に限られます。updateOneやfindOneAndUpdateでは既定でバリデータが走らず、runValidators: trueを付けた場合でも、requiredが失敗するのは$unsetでキーを明示的に消したときだけです。「必須にしたのに更新でnullが入った」という不具合の多くはここに原因があります。
uniqueがバリデータではない理由
公式FAQはuniqueについて「Mongoose doesn’t handle unique on its own」と述べ、{ name: { type: String, unique: true } }は「MongoDBのユニークインデックスを作る省略記法にすぎない」と説明しています。したがって重複を弾いているのはMongooseではなくMongoDB側のインデックスであり、インデックスが張り終わる前に書き込むと重複がそのまま入ります。
await User.init();
await User.create({ email: '[email protected]', password: 'x' });
Model.init()はインデックス構築の完了を待つPromiseを返します。テストや初期化処理でこれをawaitしてから書き込めば、重複は期待どおりE11000エラーになります。ただし返るのはバリデーションエラーではなくMongoDBのドライバエラーなので、判定はerr.code === 11000で行ってください。
さらに公式FAQは「本番環境ではMongooseに任せずMongoDBシェルでインデックスを作るべき」「mongooseはインデックス管理の解決策ではない」とまで書いています。起動のたびにautoIndexが走ると、大きなコレクションでは接続直後に負荷が出ます。公式が本番でのインデックス自動作成を明確に否定している以上、autoIndex: falseとマイグレーションによる別管理を既定の構成と考えてください。インデックス設計そのものはMongoDBのインデックス完全ガイド:種類・作成・ESR・効果確認にまとめています。
select: falseの上書き方法
スキーマでselect: falseを付けたパスは、以降のクエリ結果から既定で外れます。用途はパスワードハッシュのような、通常は返したくないフィールドです。認証処理など必要な場面だけは、プレフィクス付きのselectで個別に戻します。
const user = await User.findOne({ email }).select('+password');
公式APIドキュメントは、この+プレフィクスを「スキーマレベルの select: false を、他のフィールドを除外せずに上書きする」ものと説明しています。select('password')と書くと通常の射影として解釈され、他のフィールドが落ちてしまうので、必ず+を付けてください。
クエリと更新の書き分け
findの戻り値とleanの使いどころ
findは配列、findOneとfindByIdは単一のドキュメントかnullを返します。返ってくるのはMongooseのDocumentインスタンスで、save()や仮想プロパティ、getterが使えます。
const rows = await User.find({ age: { $gte: 20 } }).sort({ createdAt: -1 }).limit(20).lean();
公式ドキュメントはlean()について「プレーンなJavaScriptオブジェクトを返し、save・getter・setter・仮想プロパティなどMongooseの機能を持たない」と説明しています。読み取って画面に出すだけのAPIならDocument化が無駄なコストになるためlean()を付け、更新してsave()する経路では付けません。実行計画を確認したいときはexplain()をチェーンすると、ドライバが返すexplain結果をそのまま受け取れます。
refとpopulateによる参照解決
公式ドキュメントはpopulateを「指定したパスを他のコレクションのドキュメントで自動的に置き換える処理」と定義しています。スキーマ側でObjectIdの型にrefを添えて参照先モデル名を宣言し、クエリでpopulate()を呼びます。
const storySchema = new mongoose.Schema({
title: String,
author: { type: mongoose.Schema.Types.ObjectId, ref: 'Person' }
});
const Story = mongoose.model('Story', storySchema);
const story = await Story.findOne({ title: 'Casino Royale' }).populate('author');
参照先が存在しない場合、そのパスはnullになります。公式はこれを「SQLの左外部結合に相当する」と表現しているとおり、参照切れは例外にならないのでif (!story.author)の分岐を自分で書く必要があります。populateで展開したサブドキュメントは通常のDocumentとしてsave()できますが、lean()を併用した場合はプレーンオブジェクトのままです。
書き味はJOINに似ていても、実体は参照ごとに発行される追加クエリです。一覧APIで各行にpopulateを掛ければ典型的なN+1になります。返すフィールドをpopulate({ path: 'author', select: 'name' })で絞るか、参照先をまとめて1回引いてアプリ側で突き合わせる二段クエリに切り替えてください。
findOneAndUpdateが返すドキュメント
公式のチュートリアルは「By default, findOneAndUpdate() returns the document as it was before update was applied.」と明言しています。既定は更新前です。更新後を受け取りたい場合はreturnDocument: 'after'を指定します。
const doc = await User.findOneAndUpdate(
{ email: '[email protected]' },
{ $set: { age: 30 } },
{ returnDocument: 'after', runValidators: true, upsert: true }
);
日本語の解説ではnew: trueが広く使われていますが、公式は「The new and returnOriginal options are deprecated in favor of returnDocument.」として非推奨扱いにしています。新規に書くコードはreturnDocumentで統一してください。upsert: trueを併用すると、条件に一致する文書が無いときに挿入まで行います。
更新時バリデーションの有効化条件
runValidators: trueを付けたときの落とし穴は、thisの中身が変わることです。公式ドキュメントは「When running update validators, this refers to the query object instead of the document.」と説明しています。ドキュメント前提でカスタムバリデータ内から他フィールドを参照していると、更新時だけ壊れます。クエリ側ではthis.get('フィールド名')を使って更新値を取り出してください。
加えて、update validatorsが走るのは$set・$unset・$push・$addToSet・$pull・$pullAllに限られます。$incはバリデーションを通りません。数値の範囲制約を$incで破られたくないなら、読み込んでからsave()する経路に寄せるか、MongoDB側のスキーマバリデーションで二重に守る必要があります。
ObjectIdと文字列の相互変換
_idの実体はObjectIdであり、JSONに載せるときやURLパラメータと突き合わせるときに文字列との変換が必要になります。公式ガイドによるとid仮想プロパティは既定で有効で、「ドキュメントの_idを文字列にキャストして返す」getterとして働きます。
if (!mongoose.isValidObjectId(req.params.id)) {
return res.status(400).end();
}
const doc = await User.findById(req.params.id);
if (!doc) {
return res.status(404).end();
}
const idStr = doc._id.toString();
const sameAsIdStr = doc.id;
const oid = new mongoose.Types.ObjectId(req.params.id);
クエリ条件に文字列を渡した場合、スキーマ上の型がObjectIdならMongooseが自動でキャストします。ただし24桁16進数として不正な文字列はCastErrorになるため、外部入力はisValidObjectId()で先に弾いておけば、500エラーではなく400として返せます。
ただしisValidObjectId()の判定基準はバージョンで動いています。12文字の任意文字列('0123456789ab'など)は、Mongoose 7.8系ではtrueでしたが、内部ドライバがmongodb 6系に上がったMongoose 8以降はfalseです。Mongoose 9で新たにfalseへ変わったのは数値のほうで、移行ガイドが挙げているのもこの点です。いずれの場合もnew mongoose.Types.ObjectId()に渡せば「input must be a 24 character hex string」で始まるBSONErrorになります。12バイト表現を前提にしたバリデーションが残っているなら、8へ上げる時点で洗い出してください。
versionKeyによる楽観的並行制御
Mongooseはドキュメント作成時に__vというキーを付けます。公式ガイドはこれを「Mongooseが最初に作成したときに各ドキュメントへ設定するプロパティで、値は内部のリビジョン」と説明しています。既定では配列操作の競合検出に使われるだけで、ドキュメント全体の更新衝突は検出しません。
読み込みから保存までの間に他の処理が同じドキュメントを書き換えていないことを保証したい場合は、スキーマオプションにoptimisticConcurrency: trueを指定します(既定はfalse)。有効にするとsave()は__vの一致を条件に更新を行い、一致しなければVersionErrorを投げます。在庫数や残高のように「読んだ値を前提に書く」処理では、この設定の要否を実装前に決めておいてください。versionKey: falseで__v自体を消すこともできますが、その場合はこの保護も同時に失われます。
Mongooseを採用しない判断基準
最も外すべきなのは、処理の大半が集計パイプラインで占められているワークロードです。aggregate()が返す結果はスキーマの型キャストもバリデーションも通らず、Documentにもなりません。パイプラインを組み立てて結果をそのまま返すだけのAPIでは、Mongooseは依存関係とビルド時間を増やすだけの層になります。この場合は公式のMongoDB Nodeドライバを直接使うほうが、構成も型の見通しもシンプルになります。
次に外れるのが、既存コレクションのドキュメント形状が世代ごとに異なり、strict: falseで運用せざるを得ないケースです。strictを切った瞬間にスキーマは「保存される形の保証」ではなく単なる補完情報に変わり、ODMの中心的な価値が消えます。移行期間の一時措置ならともかく、恒常的にstrictを切る前提なら、スキーマを持たない構成を正面から選んだほうが誤解を生みません。
実行環境のNode.jsが20.19.0未満に固定されている場合も、Mongoose 9は選べません。8系に留まる判断自体は妥当ですが、内部のドライバも6.x系のまま据え置かれる点は把握しておいてください。
一方、スキーマを効かせる構成を選ぶ場合でも、インデックス管理は前述のとおり切り離してください。なお、グラフ的なリレーションや多モデル対応が要件の中心にあるなら、そもそもMongoDB以外の選択肢を検討する余地も残ります。比較の観点はArangoDBの主要機能と他のデータベースにはない強みとはにまとめました。
よくある質問
Mongooseの読み方は何ですか
日本語では「マングース」と読みます。動物のマングースと同じ綴りで、ライブラリ名もそのままの読みで通じます。ドキュメント上の表記は常に小文字のmongooseです。
MongooseとMongoDBは何が違うのですか
MongoDBはデータベース製品そのもの、MongooseはそれをNode.jsから扱うためのODMライブラリです。MongooseはMongoDB公式のNodeドライバを内部で使い、その上にスキーマ定義・型キャスト・バリデーション・ミドルウェアの層を追加します。Mongooseを使わなくてもMongoDBは操作できます。
unique: trueを付けたのに重複が保存されるのはなぜですか
uniqueがバリデータではなく、ユニークインデックスを作る指示にすぎないためです。インデックスの構築が終わる前に書き込むと重複が通ります。await Model.init()で構築完了を待ってから書き込むか、本番ではあらかじめインデックスを作成しておいてください。
findOneAndUpdateで更新後のドキュメントを受け取るには
returnDocument: 'after'を指定します。既定は更新前のドキュメントです。従来よく使われたnew: trueは公式に非推奨とされているため、新規コードではreturnDocumentを使ってください。
Mongoose 8から9へ上げるとき最初に直すのはどこですか
実行環境のNode.jsが20.19.0以上かを確認したうえで、nextを受け取る形式のpreミドルウェアを洗い出してください。9ではnextが渡されないためTypeErrorで止まります。次に更新パイプラインを使っている箇所へupdatePipeline: trueを足し、TypeScriptならFilterQueryをQueryFilterへ置換します。