Choosing Mongoose Discriminators Over Separate Collections: A Decision Guide
Decide between Mongoose discriminators, separate collections, and manual type fields with a comparison table, a working discriminator setup, partial unique indexes, and validation steps.
01 Aug 2026, 12:39 UTC

The decision: one collection or several
You have several document types that share an identity — say, articles, videos, and podcasts in a CMS — and you need to decide how to model them in MongoDB with Mongoose. The three supported options are: separate collections per type, one collection with a plain schema and a type field you manage yourself, or Mongoose discriminators, where multiple models share one collection and Mongoose manages the subtype distinction for you.
The takeaway: if you frequently query across subtypes and want per-subtype validation, middleware, and hydration without hand-rolling a type field, discriminators are the right default. If subtypes are queried independently and have divergent index needs, separate collections are simpler to reason about.
Comparing the supported options
| Concern | Separate collections | Manual type field | Discriminators |
|---|---|---|---|
| Cross-subtype queries | Requires $unionWith or application-side merge | Single query, manual filtering | Single query via base model |
| Per-subtype validation | Native, one schema each | Conditional validators you write | Native, one schema each |
| Hydration to correct class | Automatic | Manual mapping | Automatic via __t key |
| Index design | Obvious per collection | Shared indexes, partial indexes for subtype fields | Shared indexes, partial indexes for subtype fields |
| Schema evolution | Independent | One schema grows messy | Independent per subtype |
| Middleware per subtype | Native | Must branch manually | Native (base middleware also applies) |
Trade-offs to accept with discriminators
Discriminators store all subtypes in one collection, distinguished by a discriminator key (default __t) holding the registered model name. That gives you clean cross-subtype reads, but you accept these constraints:
- Subtype is fixed at creation. You cannot convert an Article into a Video by changing
__t; you would delete and re-create the document. - Heterogeneous documents. Subtype-only fields exist on some documents and not others, so shared indexes on those fields need
partialFilterExpressionto stay efficient and to scope uniqueness correctly. - Base middleware applies to subtypes unless you deliberately structure hooks. A
pre('save')on the base schema runs for every subtype. - Aggregation bypasses model conveniences. Pipelines do not filter by subtype automatically; match on
__texplicitly when subtype semantics matter. - Population should reference the base model when a stored ref might resolve to any subtype, so Mongoose can hydrate the correct discriminator model.
Concrete implementation
The following assumes Mongoose 6 or newer (check with npm ls mongoose; the discriminator API has been stable across recent majors). The base model must exist before you register subtypes.
const mongoose = require('mongoose');
const { Schema } = mongoose;
const contentSchema = new Schema(
{
title: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: 'User', required: true },
publishedAt: Date,
},
{ timestamps: true }
);
const Content = mongoose.model('Content', contentSchema);
const articleSchema = new Schema({ body: String, slug: String });
const videoSchema = new Schema({ url: String, durationSec: Number });
const Article = Content.discriminator('Article', articleSchema);
const Video = Content.discriminator('Video', videoSchema);
Now a subtype-scoped unique slug. A plain unique: true on slug would apply across all subtypes (and to documents missing the field), so scope it with a partial index:
articleSchema.index(
{ slug: 1 },
{ unique: true, partialFilterExpression: { __t: 'Article' } }
);
Define this index before calling discriminator() so it lands on the shared collection. With it in place, two Articles cannot share a slug, but an Article and a Video can.
Validating the behavior
Insert one of each subtype through their models, then query through the base:
await Article.create({ title: 'A', author: userId, slug: 'a', body: '...' });
await Video.create({ title: 'V', author: userId, url: 'https://...', durationSec: 90 });
const all = await Content.find(); // mixed subtypes, hydrated correctly
console.log(all.map(d => d.constructor.modelName)); // ['Article', 'Video']
Run this in your application context with a connected Mongoose instance; no special permissions beyond normal collection writes are needed. Confirm the stored key in mongosh:
db.contents.find({}, { __t: 1, title: 1 })
Each document's __t should equal the registered discriminator name ("Article", "Video"). Then attempt a duplicate Article slug and confirm it fails with a duplicate-key error, while a Video with the same slug succeeds.
Migration caveat: pre-existing documents
If the collection already contains documents inserted before you introduced discriminators, they have no __t and will hydrate as base-model instances — subtype fields and methods will be missing. Verify by inserting a raw document without __t via mongosh and reading it through Content.find(). If that affects production data, plan a backfill (db.contents.updateMany({ __t: { $exists: false } }, { $set: { __t: 'Article' } }) or similar) before relying on subtype behavior.
When to pick something else
Choose separate collections when subtypes are never queried together, have heavy subtype-specific index workloads, or need independent sharding/retention policies. Choose embedded subdocuments when the variant data has no independent identity and is always read with its parent. Discriminators fit the middle: shared identity, shared queries, distinct shapes — CMS content types, event records with variant payloads, and role-specific users are the classic matches.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.