データベース

Mongooseとは?Node.js用MongoDB ODMの使い方と9.11の変更点

Mongooseとは?Node.js用MongoDB ODMの使い方と9.11の変更点

Mongooseは、MongoDBをNode.jsからスキーマ付きで扱うためのODM(Object Data Modeling)ライブラリです。同じ綴りの自転車ブランドや動物とは無関係で、この記事で扱うのはnpmパッケージのmongooseです。2026年11月2日時点の最新版は2026年10月23日公開の9.11.4で、Node.js 20.19.0以上を要求します。インストールと接続から、required・unique・selectの実挙動、ページング、クエリインジェクション対策、8系からの移行まで、mongoose 9.11.4をMongoDB 9.0.4に接続して実行した結果をもとに整理します。

まとめ

  • MongooseはMongoDB公式のNodeドライバの上に、スキーマ・型キャスト・バリデーション・ミドルウェアを足すODMです。スキーマが効くのはMongoose経由の書き込みだけです。
  • 最新は9.11.4(内部ドライバ7.7系・Node.js 20.19.0以上)。8系も8.24.7まで保守が続いています。9.11.4と8.24.7はプロトタイプ汚染とsanitizeFilterの修正を含むので、どちらの系列でも最新パッチへ上げてください。
  • unique: trueはバリデータではなくユニークインデックスの作成指示です。既存データに重複があるとインデックス作成が失敗し、その後は重複が保存されます。
  • 更新系のバリデーションは既定で動きません。runValidators: trueを付けても$incは検査されません。
  • findOneAndUpdateの既定の戻り値は更新前のドキュメントです。更新後はreturnDocument: 'after'で受け取ります(new: trueは9.11.4で実行時に非推奨警告が出ます)。
  • リクエストの値をそのまま検索条件に渡す実装は、{ $ne: null }の注入で全件が返ります。sanitizeFilterを有効にしてください。

MongooseとMongoDBドライバの役割の違い

ODMが足す層

MongoDBはドキュメントごとに構造が違っても保存できるデータベースです。Mongooseは「このコレクションのドキュメントは必ずこの形」という約束をスキーマとして宣言し、保存前に型キャストとバリデーションを行います。仮想プロパティ、ミドルウェア(フック)、参照を展開するpopulateもこの層の機能です。MongoDB本体の構造はMongoDBとは?ドキュメント指向データベースの仕組み・利点・使い方入門【9.0対応】で整理しています。

公式のMongoDB NodeドライバはMongooseのスキーマ層を持ちませんが、BSON変換やコマンド送信に加え、接続プールの管理なども担います。Mongooseはドライバを置き換えるのではなくラップしており、npmレジストリ上のmongoose 9.11.4はmongodb ~7.7、8.24.7は~6.20.0に依存しています。リレーショナルDB向けのORMとの考え方の違いはORMとは?O/Rマッパーの仕組み・メリットとデメリットをSQL比較と実行結果で解説が参考になります。

注意したいのは、スキーマの強制がアプリ側だけで完結している点です。MongoDB公式のMongoose統合のページも、MongoDBサーバーはスキーマを認識せず、Mongooseを通らないクライアントは検証を迂回できると説明しています。mongoshや別言語のバッチが同じコレクションに書くなら、Mongooseのスキーマは保証になりません。

自転車ブランド・組み込みサーバーとの区別

「mongoose」で検索すると、BMXブランドのMONGOOSE、動物のマングース、Cesanta製の組み込みWebサーバーMongooseが混ざります。npmのmongooseパッケージが指すのはMongoDB用ODMだけです。技術情報を探すときは「mongoose mongodb」「mongoose schema」のように文脈語を足すと、公式ドキュメントが上位に来ます。

Mongooseのインストールと接続設定

npm installと動作要件

パッケージはnpm install mongooseで入ります。MongoDB本体は別に用意します。ローカルならDockerの公式イメージ、クラウドならMongoDB Atlasとは?クラスタ階層・料金・セキュリティ設定と採用判断を実装者目線で解説【2026年版】で解説しているAtlasの無料クラスタが手早い選択肢です。

npm install mongoose
docker run -d --name mongo -p 127.0.0.1:27017:27017 mongo:8.0

Mongoose 9の移行ガイドは「Mongoose 9 requires Node.js 20.19.0 or higher.」と明記しています。要件はパッチ番号まで含むため、20.18.xはサポート対象外です。npmの既定設定では、要件不一致でも警告のみでインストールされる場合があります。Node.jsの版の選び方はNode.jsとは?仕組み・版の選び方・標準機能で動かす手順を実装目線で解説を参照してください。対応するMongoDBサーバーの範囲は公式の互換性ページに載っています。

MongoDBサーバー 対応するMongoose
8.x ^8.7.0 または ^9.0.0
7.x ^7.4.0 / ^8.0.0 / ^9.0.0
6.x ^7.0.0 / ^8.0.0 / ^9.0.0

2026年11月時点の互換表にMongoDB 9.xの行はまだありません。ただしCHANGELOGには9.10.4と8.24.5で「MongoDB server 9.x」が全インデックスにcollation: { locale: 'simple' }を返す件への修正が入っています。サーバーを9系へ上げるなら、Mongoose側はこれ以降の版にしてください。

connectの書き方と古いオプション

接続はアプリ起動時に一度だけ行います。mongoose.connectはPromiseを返すので、awaitして失敗を検知します。トップレベルawaitが使えるESMで書くのが素直です。

import mongoose from 'mongoose';

await mongoose.connect(process.env.MONGODB_URI);
// 例: mongodb://127.0.0.1:27017/appdb

CommonJSのrequireではトップレベルawaitがSyntaxErrorになるため、async function main()で包んで呼び出します。ESMへ変更する場合は拡張子を.mjsにし、requireもimportへ置き換えます。接続文字列の末尾がデータベース名で、Atlasならmongodb+srv形式のURIをそのまま渡します。

古い記事に出てくるuseNewUrlParserとuseUnifiedTopologyは、渡すと接続そのものが失敗します。9.11.4でconnect(uri, { useNewUrlParser: true, useUnifiedTopology: true })を実行すると、MongoParseError: options usenewurlparser, useunifiedtopology are not supportedで止まりました。コピーしたコードからは削除してください。URIは環境変数から読み、待ち受けは127.0.0.1に絞ります。公開されたMongoDBが狙われた例としてMongoDBの脆弱性CVE-2025-14847(MongoBleed)とは?影響バージョン・確認手順とzlib無効化の対策【2026年10月時点】があります。

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 },
  role:     { type: String, enum: ['user', 'admin'], default: 'user' }
}, { timestamps: true });

const User = mongoose.model('User', userSchema);

strictは既定でtrueです。スキーマに無いfooを渡してcreate()すると、保存されたドキュメントからfooは消えていました。timestampsを有効にするとcreatedAtとupdatedAtが自動で付きます。

TypeScriptではInferSchemaType<typeof userSchema>でスキーマから型を導くと、インターフェースを二重管理せずに済みます。9系では検索条件の型FilterQueryがQueryFilterへ改名されました。

MongooseモデルのStandard Schema対応

9.7.0(2026年6月9日)で、モデルにStandard SchemaのアダプタModel['~standard']が加わりました。Standard Schemaに対応したフォーム検証やAPIフレームワークへ、Zodのスキーマと同じようにMongooseのモデルを渡せます。仕組みはStandard Schemaとは?Zod・Valibot・ArkTypeを共通で受け取る仕組みと実装例で解説しています。

const result = await User['~standard'].validate({ age: -1 });
// { issues: [
//   { message: 'Path `email` is required.', path: ['email'] }, ... ] }

内部ではModel.validate()を呼ぶだけなので、DBへの接続は不要です。{ email: 'a@x', password: 'example-hash', age: '3' }を渡すとageが数値の3にキャストされたvalueが返りました。ユニーク制約はインデックスの仕事なので、この検証では検出できません。

フィールドオプションの実挙動

requiredが効く範囲

requiredは組み込みバリデータで、文字列なら空文字も未設定として弾きます。効くのはsave()とcreate()で、updateOneやfindOneAndUpdateでは既定で検査されません。

公式のバリデーションのドキュメントは、runValidators: trueを付けたときrequiredが失敗するのは「explicitly $unset the key」した場合だけと説明しています。ところが9.11.4で{ $set: { email: null } }をrunValidators: true付きで実行すると、$unsetと同じくValidationError: Validation failed: email: Path `email` is required.になりました。確実に言えるのは、更新内容に含めなかったパスはrequiredを含めて検査されない、という点です。

uniqueがバリデータではない理由

公式のFAQは「Mongoose doesn’t handle unique on its own」と書き、unique: trueはMongoDBのユニークインデックスを作る省略記法だと説明しています。重複を弾くのはMongoDBなので、エラーはValidationErrorではなくMongoServerErrorのcode 11000(E11000 duplicate key error)です。判定はerr.code === 11000で行います。

見落とされやすいのは、既に重複があるコレクションに後からuniqueを付けた場合です。同じemailを2件入れたコレクションでモデルを作ると、Model.init()はMongoServerError: Index build failedでrejectし、インデックスは_id_だけのままでした。この状態で同じemailをcreate()すると、エラーなく3件目が保存されます。起動時にinit()のrejectかindexイベントを監視していないと気付けません。

公式は本番でのインデックス自動作成を推奨しておらず、スキーマガイドでもautoIndex: falseで無効化する方法を示しています。本番はautoIndex: falseにして、マイグレーションで重複を解消してからインデックスを作る運用にしてください。設計の考え方はMongoDBのインデックス設計:createIndexの書き方・ESRの並び順・explainの読み方【9.0で実測】にまとめています。

select: falseの上書き

select: falseを付けたパスは既定でクエリ結果から外れます。パスワードハッシュのように通常は返さないフィールド向けです。認証処理など必要な場面だけ+付きで戻します。

const user = await User.findOne({ email }).select('+password');

9.11.4で比べると、select('+password')は他のフィールドを残したままpasswordを加え、select('password')は_idとpasswordの2つだけを返しました。+を落とすと通常の射影になる点に注意してください。

クエリと更新の書き分け

findの戻り値とlean

findは配列、findOneとfindByIdはドキュメントかnullを返します。戻り値はMongooseのDocumentで、save()や仮想プロパティが使えます。読み取って返すだけのAPIならlean()でプレーンなオブジェクトにすると、Document化の費用を省けます。

const rows = await User.find({ age: { $gte: 20 } }).sort({ createdAt: -1 }).limit(20).lean();

lean()の結果はDocumentではないため、_idを文字列で返すid仮想プロパティもundefinedでした。フロントエンドがidを前提にしているなら、lean()を付けた経路だけ壊れます。

refとpopulateによる参照解決

ObjectId型にrefで参照先モデル名を書き、クエリでpopulate()を呼ぶと、IDが参照先のドキュメントに置き換わります。

const storySchema = new mongoose.Schema({
  title:  String,
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'Person' }
});
const Person = mongoose.model('Person', new mongoose.Schema({ name: String }));
const Story = mongoose.model('Story', storySchema);

const story = await Story.findOne({ title: 'Casino Royale' }).populate('author');

参照先が存在しないIDを入れておくと、story.authorは例外にならずnullになりました。参照切れの分岐は自分で書く必要があります。populateは通常、同じ参照先モデルのIDをまとめて取得します。Storyを10件引いてpopulate('author')すると、発行されたクエリはstoriesとpeopleへの2本でした。perDocumentLimitなどの条件ではドキュメントごとの追加クエリが発生しますが、取得列を絞ってもクエリ数は減りません。取得データ量や結合処理を見直す場合は、populate({ path: 'author', select: 'name' })で返す列を絞るか、集計の$lookupに切り替えます。

findOneAndUpdateの戻り値

公式のfindOneAndUpdateのチュートリアルは「By default, findOneAndUpdate() returns the document as it was before update was applied.」と書いています。実際にageを20から30へ更新すると、戻り値のageは20でした。

const doc = await User.findOneAndUpdate(
  { email: '[email protected]' },
  { $set: { age: 30 } },
  { returnDocument: 'after', runValidators: true }
);

日本語の解説で多いnew: trueも動きますが、9.11.4では実行時に「the `new` option for `findOneAndUpdate()` and `findOneAndReplace()` is deprecated. Use `returnDocument: ‘after’` instead.」という警告が出ました。非推奨化は9.2.0からです。新しく書くコードはreturnDocumentにしてください。

findAndCountによるページ取得と総件数の取得

一覧APIでは「今のページの行」と「総件数」を同時に返すことがよくあります。9.10.0でModel.findAndCount()、9.11.0でクエリをつないだ末尾に付けるQuery.prototype.findAndCount()が加わりました。クエリのドキュメントによると、中身はfind()とcountDocuments()をPromise.allで並べたものです。

const [items, total] = await User.find({ role: 'user' })
  .sort({ createdAt: -1, _id: -1 }).skip(20).limit(20).findAndCount();

12件のうち10件が条件に合うデータでskip(3).limit(4)を付けると、返ったのは4件の配列と総件数10でした。件数側はskipとlimitの影響を受けません。ただし同じドキュメントが注意しているとおり、skipは飛ばす件数だけ走査するので、深いページでは遅くなります。深いページまで参照し、skipによる走査量が問題になる一覧は、前ページ最後の_idや日時を条件にするカーソル方式にしてください。

更新時バリデーションの有効化条件

runValidators: trueを付けると、カスタムバリデータ内のthisがドキュメントではなくクエリになります。他フィールドを参照するバリデータは更新時だけ壊れるので、this.get('フィールド名')で更新値を取り出します。ただし、更新に含めないフィールドの保存済み値は取得できません。

update validatorsが走るのは$set・$unset・$push・$addToSet・$pull・$pullAllだけです。min: 0のageが1のドキュメントに{ $inc: { age: -10 } }をrunValidators: true付きで当てると、エラーにならず-9が保存されました。$set: { age: -5 }は同じ条件でValidationErrorになります。残高や在庫のように$incで動かす値は、MongoDB側の$jsonSchema検証か、条件付き更新({ qty: { $gte: n } }をフィルタに入れる)で守ってください。

sanitizeFilterによるクエリインジェクション対策

ExpressでUser.find({ email: req.body.email })と書くと、JSONで{"email": {"$ne": null}}を送られた時点で条件が「emailがnullでない」に変わります。手元の27件のコレクションで試すと、27件すべてが返りました。ログイン処理なら認証の迂回につながります。

mongoose.set('sanitizeFilter', true);

// 意図して演算子を使う箇所だけ trusted() で包む
await User.find({ age: mongoose.trusted({ $gte: 20 }) });

APIドキュメントによると、sanitizeFilterは名前が$で始まるキーを持つ入れ子のオブジェクトを$eqで包みます。有効にして同じ値を渡すと、CastError: Cast to string failed for value "{ '$ne': null }"で検索自体が失敗しました。クエリ単位ならsetOptions({ sanitizeFilter: true })でも同じ結果です。

版にも注意が要ります。CHANGELOGによると、countDocuments()とcursor()への適用は9.9.2と8.24.3から、createConnection()で設定した接続単位のsanitizeFilterがクエリに反映されるようになったのは9.11.4と8.24.7からです。同じ版ではwhere()やmod()に__proto__などの特殊なパスを渡せないようにし、依存するmqueryも上げてプロトタイプ汚染を塞いでいます。

ObjectIdと文字列の相互変換

_idの実体はObjectIdで、JSONやURLパラメータと突き合わせるときに文字列との変換が要ります。Documentではdoc.idが_idを文字列にした値を返します。

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();

不正な文字列をそのままfindById('abc')に渡すと、CastError: Cast to ObjectId failed for value "abc"で例外になり、何もしなければ500エラーです。isValidObjectId()で先に弾けば400で返せます。この関数は版で判定が変わっており、12文字の任意文字列('0123456789ab')はMongoose 7.8系ではtrue、8系以降はfalseです。数値は9系でfalseになり、9.11.4ではisValidObjectId(6)もfalseでした。

versionKey(__v)と楽観的並行制御

Mongooseはドキュメント作成時に__vを付けます。既定では配列操作の競合検出に使われるだけで、ドキュメント全体の上書きは防ぎません。同じドキュメントを2つの処理で読み、それぞれqtyを9と8に変えて順にsave()すると、最終値は後勝ちの8でした。

スキーマにoptimisticConcurrency: trueを付けると、save()は__vの一致を条件に更新します。同じ手順で2回目のsave()はVersionError: No matching document found for id ... version 0になりました。読んだ値を前提に書く処理では、この設定の要否を実装前に決めておいてください。versionKey: falseで__vを消すと、この保護も失われます。

Mongoose 8から9へ上げる手順

9.0.0は2025年11月21日に出ています。移行ガイドの変更点のうち、実行時に止まるものを先に直します。

対象 9での挙動(9.11.4で確認) 対応
Node.js 20.19.0以上が必須 ランタイムを更新
preミドルウェア next is not a function async関数へ書き換え
更新パイプライン 配列を渡すとMongooseError updatePipeline: true
isValidObjectId(数値) false 数値を渡す経路を修正
index の background オプション削除 指定を外す
FilterQuery(TS) QueryFilter へ改名 型名を置換

影響が大きいのはpreミドルウェアです。8までのfunction (next) { ...; next(); }は、9.11.4でTypeError: next is not a functionとなりcreate()がrejectされました。async関数に書き換えます。

// この登録は mongoose.model('User', userSchema) より前に配置する
userSchema.pre('save', async function () {
  this.email = this.email.trim().toLowerCase();
});

更新パイプライン(updateOne(filter, [{ $set: ... }]))は、MongooseError: Cannot pass an array to query updates unless the `updatePipeline` option is set.で止まり、{ updatePipeline: true }を付けると通りました。8系に留まる場合でも、セキュリティ修正の入った8.24.7以上へは上げてください。

Mongooseを採用しない判断基準

最初に外すべきなのは、処理の大半が集計パイプラインのワークロードです。aggregate()はスキーマのキャストを通りません。ageが数値のコレクションでfind({ age: '20' })は1件を返し、aggregate([{ $match: { age: '20' } }])は0件でした。パイプラインを組んで結果を返すだけのAPIなら、Mongooseは依存とビルド時間を増やす層にしかならないので、公式ドライバを直接使います。

次に、同じコレクションへMongoose以外のクライアントも書き込む構成です。前述のとおりスキーマはMongoose経由でしか効かないため、保証が必要ならMongoDB側の$jsonSchema検証を主にし、Mongooseは補助に回します。既存データの形が世代ごとに違いstrict: falseで恒常運用する場合も、未定義フィールドを制限する利点は失われます。ただし、定義済みフィールドのキャストやバリデーション、ミドルウェアの価値は残るため、それらを使うかで採用を判断します。

逆に、Expressなどで書くCRUD中心のAPIで、書き込み経路がアプリに限られるなら、Mongooseのスキーマ・バリデーション・populateは十分に元が取れます。リレーショナルDBを選ぶ余地があるなら、TypeScriptのORMとしてTypeORMとは?1.1系の使い方|エンティティ・CRUD・リレーション・マイグレーションを実行して解説なども比較してください。

よくある質問

Mongooseの読み方は何ですか?

「マングース」と読みます。動物のマングース(mongoose)と同じ綴りで、ライブラリ名も同じ読みで通じます。表記は小文字のmongooseが一般的です。

MongooseとMongoDBは何が違うのですか?

MongoDBはデータベース製品、MongooseはそれをNode.jsから扱うODMライブラリです。MongooseはMongoDB公式のNodeドライバを内部で使い、スキーマ定義・型キャスト・バリデーション・ミドルウェアを足します。Mongooseを使わなくてもMongoDBは操作できます。

unique: trueを付けたのに重複が保存されるのはなぜですか?

uniqueはバリデータではなく、ユニークインデックスを作る指示だからです。既存データに重複があるとインデックス作成が失敗し、以後は重複が通ります。await Model.init()のエラーを確認し、重複を解消してからインデックスを作ってください。

__vは消してもよいですか?

versionKey: falseで消せますが、配列操作の競合検出とoptimisticConcurrencyによる上書き防止が使えなくなります。APIの応答から隠したいだけなら、toJSONの変換で除外するほうが安全です。

findOneAndUpdateで更新後のドキュメントを受け取るには?

returnDocument: 'after'を指定します。既定は更新前のドキュメントです。new: trueは9.2.0から非推奨で、9.11.4では実行時に警告が出ます。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.03 テックブログ クラブツーリズム不正アクセス:約432万件の対象確認と偽メールの見分け方・流出検知の設計
  2. 2026.11.04 テックブログ CVE-2026-119977:WordPress更新中のRCEと安全な更新手順
  3. 2026.10.29 テックブログ ソニー生命の情報漏洩38.5万件|ログインできない理由、再開状況と窓口の電話番号
  4. 2026.11.03 テックブログ ノジマ不正アクセスで約700万件漏えい:対象確認・窓口と乗っ取り注文を止める実装
  5. 2026.11.02 テックブログ アララの不正アクセス:メール約7,700万件の持ち出しと契約企業が行うAPIキー再設定・代替送信・連名報告

RELATED POSTS 関連記事

目次