---
title: "Mongooseとは？Node.js用MongoDB ODMの使い方と9.11の変更点"
url: "https://www.issoh.co.jp/tech/details/3588/"
published: 2024-09-13
updated: 2026-11-02
categories: ["データベース"]
publisher: "株式会社一創"
---

# 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対応】](/column/details/3006/)で整理しています。

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

注意したいのは、スキーマの強制がアプリ側だけで完結している点です。MongoDB公式の[Mongoose統合のページ](https://www.mongodb.com/ja-jp/docs/drivers/node/current/integrations/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年版】](/tech/details/16446/)で解説しているAtlasの無料クラスタが手早い選択肢です。

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

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

| 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](https://github.com/Automattic/mongoose/blob/master/CHANGELOG.md)には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月時点】](/tech/details/10540/)があります。

## 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を共通で受け取る仕組みと実装例](/tech/details/11777/)で解説しています。

```
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`では既定で検査されません。

公式の[バリデーションのドキュメント](https://mongoosejs.com/docs/validation.html)は、`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](https://mongoosejs.com/docs/faq.html)は「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`イベントを監視していないと気付けません。

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

### 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のチュートリアル](https://mongoosejs.com/docs/tutorials/findoneandupdate.html)は「By default, findOneAndUpdate() returns the document as it was before update was applied.」と書いています。実際に`age`を20から30へ更新すると、戻り値の`age`は20でした。

```
const doc = await User.findOneAndUpdate(
  { email: 'a@example.com' },
  { $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()`が加わりました。[クエリのドキュメント](https://mongoosejs.com/docs/queries.html)によると、中身は`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ドキュメント](https://mongoosejs.com/docs/api/mongoose.html)によると、`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・リレーション・マイグレーションを実行して解説](/tech/details/7599/)なども比較してください。

## よくある質問

### 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では実行時に警告が出ます。

## 関連記事

- [MongoDBとは？ドキュメント指向データベースの仕組み・利点・使い方入門【9.0対応】](/column/details/3006/)
- [MongoDBのインデックス設計：createIndexの書き方・ESRの並び順・explainの読み方【9.0で実測】](/tech/details/2626/)
- [MongoDB Atlasとは？クラスタ階層・料金・セキュリティ設定と採用判断を実装者目線で解説【2026年版】](/tech/details/16446/)
- [ORMとは？O/Rマッパーの仕組み・メリットとデメリットをSQL比較と実行結果で解説](/tech/details/3755/)
- [Node.jsとは？仕組み・版の選び方・標準機能で動かす手順を実装目線で解説](/tech/details/17433/)

---

出典: [Mongooseとは？Node.js用MongoDB ODMの使い方と9.11の変更点](<https://www.issoh.co.jp/tech/details/3588/>)（株式会社一創）
