MongoDB Modeling skill for Claude Code
Model documents for reads, not for an ERD. Claude starts from a workload table, then decides per relationship whether to embed, reference, or use subset, bucket and extended-reference patterns. Arrays stay bounded, compound indexes follow Equality-Sort-Range, collections get $jsonSchema validators with Decimal128 money and real dates, and every hot query is checked with explain executionStats so documents examined stay close to documents returned.
- name
mongodb-modeling - size226 lines · ≈2,600 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs MongoDB document models from access patterns - embedding vs referencing, bounded arrays, extended reference, subset, bucket, computed and schema-versioning patterns - plus compound indexes ordered by the ESR rule, explain("executionStats") analysis, $jsonSchema validation, and aggregation pipelines that use indexes. Use when designing collections, a MongoDB query is slow or shows COLLSCAN, documents are growing toward the 16 MB limit, writing $lookup/$group pipelines, or reviewing a Mongoose/driver schema. Try asking
- Design MongoDB collections for a marketplace with products, orders, and reviews
- This Mongo query does a COLLSCAN on 40M documents - fix the index
- Our post documents keep growing because comments are embedded. How should I restructure?
Use it when
- You are designing new MongoDB collections or a Mongoose schema
- A query shows COLLSCAN, an in-memory SORT, or high docs examined
- Documents grow without bound or approach the 16 MB limit
- You are writing aggregation pipelines with $lookup or $group
Skip it when
- The data is relational with heavy cross-entity constraints - use postgres-schema-design
- You are modeling Firestore documents and security rules - use firebase-backend
How to check the result
Run explain("executionStats") on each hot query against production-sized data: IXSCAN with no blocking SORT, and totalDocsExamined roughly equal to nReturned.
The common mistake it prevents
Embedding an unbounded array such as comments or events works in testing, then every write rewrites a multi-megabyte document and the 16 MB limit eventually rejects inserts.
A MongoDB model is right when the most frequent reads fetch one document (or one index
range) with no $lookup, every array has a known upper bound, each hot query is served
by an index with totalDocsExamined close to nReturned, and the collection enforces a
validator. Model for how the data is read, not for how it looks in an ERD.
Workflow
- [ ] 1. List the workload: queries, writes, frequency, latency target, data sizes
- [ ] 2. Identify entities and relationship cardinalities (1:few, 1:many, 1:zillions, N:M)
- [ ] 3. Decide embed vs reference per relationship (table below)
- [ ] 4. Apply patterns for growth, hot fields, and history
- [ ] 5. Design indexes per query with ESR ordering
- [ ] 6. Add $jsonSchema validation
- [ ] 7. Verify with explain("executionStats") on realistic data
1. Workload first
Write a table before designing anything:
| Operation | Type | Frequency | Fields filtered/sorted | Latency target |
|---|---|---|---|---|
| Show order with items | read | 2k/s | _id | 20 ms |
| Customer order history | read | 200/s | customerId, sort placedAt desc | 50 ms |
| Add item to cart | write | 500/s | _id | 30 ms |
3. Embed or reference
| Relationship | Default | Why |
|---|---|---|
| 1:few, always read together (addresses on a user) | Embed array | One read, atomic update |
| 1:many bounded (< ~100 items, line items of an order) | Embed | Order is read as a unit |
| 1:many unbounded or large (comments on a viral post, events of a device) | Reference from child (postId on comment) | Avoid unbounded arrays and 16 MB limit |
| Child read independently and often | Reference | Avoid rewriting the parent on each child update |
| N:M (students and courses) | Array of IDs on the side with smaller fan-out, or a join collection if the edge has attributes | Bounded arrays only |
| Data that changes on its own schedule (product price) used in history (order) | Copy the snapshot into the order | It is a historical fact, not duplication |
Rules of thumb:
- Things that are written together and read together belong in one document. Single-document writes are atomic without transactions.
- Never let an array grow without bound. Large arrays make every update rewrite and re-index a huge document; multikey indexes on them balloon.
- Documents have a hard 16 MB limit; design to stay well under 1 MB for hot documents.
4. Patterns
| Pattern | Use when | Shape |
|---|---|---|
| Extended reference | You join to show a few fields of another entity | Store { customerId, customerName } on the order; accept refreshing on rename |
| Subset | Parent has many children but views show the latest N | Embed latest 10 reviews; full set in reviews collection |
| Bucket | Time series or high-volume children | One doc per device per hour with an array of readings, plus count; or use native time series collections |
| Computed | Aggregates read far more than written | Maintain commentCount, ratingSum with $inc on write |
| Outlier | Few documents exceed normal size | Flag hasOverflow: true and spill extra items to an overflow collection |
| Polymorphic | Variants of one entity queried together | Same collection, type discriminator, shared indexed fields |
| Schema versioning | Shape changes over time | schemaVersion field; app reads both, migrates lazily or with a background job |
| Archive | Old data rarely read | Move to an archive collection or use TTL index to expire |
Native time series collections (MongoDB 5.0+, much improved in later versions) handle bucketing automatically for metrics and events:
db.createCollection("readings", {
timeseries: { timeField: "ts", metaField: "sensor", granularity: "minutes" },
expireAfterSeconds: 60 * 60 * 24 * 90,
});
5. Indexes: the ESR rule
Order compound index keys as Equality, Sort, Range:
// Query: { tenantId: X, status: "open", createdAt: { $gte: since } } sort { priority: -1 }
db.tickets.createIndex({ tenantId: 1, status: 1, priority: -1, createdAt: 1 });
// E E S R
- Putting the range field before the sort field forces an in-memory
SORTstage. - Sort direction matters for multi-field sorts: index
{a: 1, b: -1}supportssort({a: 1, b: -1})andsort({a: -1, b: 1}), notsort({a: 1, b: 1}). - Covered queries: project only indexed fields and exclude
_id(unless indexed) so the plan showsPROJECTION_COVEREDwithtotalDocsExamined: 0. - Partial indexes for sparse predicates:
createIndex({ email: 1 }, { unique: true, partialFilterExpression: { isDeleted: false } }). Partial filters support only a limited operator set (equality,$exists: true, comparisons,$type, and$and/$or/$inon recent versions); the query must include the filter condition for the planner to use the index. - TTL index for expiry:
createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }). - Unique indexes enforce business keys; scope them:
{ tenantId: 1, slug: 1 }, { unique: true }. - Limit: 64 indexes per collection, but every index costs writes and RAM. Keep the working set of indexes in memory.
- Build indexes on large production collections during low traffic; modern versions build without holding an exclusive lock for the whole build but still consume resources. On replica sets, monitor replication lag.
6. Validation
db.runCommand({
collMod: "orders",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["customerId", "status", "items", "placedAt"],
properties: {
customerId: { bsonType: "objectId" },
status: { enum: ["pending", "paid", "shipped", "cancelled"] },
items: {
bsonType: "array", maxItems: 200,
items: {
bsonType: "object", required: ["sku", "qty", "unitPrice"],
properties: {
sku: { bsonType: "string" },
qty: { bsonType: "int", minimum: 1 },
unitPrice: { bsonType: "decimal" },
},
},
},
placedAt: { bsonType: "date" },
},
},
},
validationLevel: "moderate", // existing invalid docs are not re-checked on unrelated updates
validationAction: "error", // "warn" first when retrofitting onto existing data
});
- Money as
Decimal128(bsonType: "decimal"), never double. - Dates as BSON
date, never ISO strings (string comparisons and range queries break). - Retrofitting: start with
validationAction: "warn", find violators withdb.orders.find({ $nor: [ { $jsonSchema: <schema> } ] }), fix, then switch to"error".
7. Verify with explain
db.tickets.find({ tenantId: t, status: "open" }).sort({ priority: -1 }).explain("executionStats");
Check:
| Field | Healthy |
|---|---|
winningPlan stages | IXSCAN -> FETCH (or covered), no COLLSCAN, no blocking SORT |
totalKeysExamined / nReturned | close to 1 |
totalDocsExamined / nReturned | close to 1 (0 for covered queries) |
executionTimeMillis | within the latency target on production-sized data |
Use the profiler or Atlas Query Insights / Performance Advisor to find slow operations:
db.setProfilingLevel(1, { slowms: 100 });
db.system.profile.find().sort({ ts: -1 }).limit(10);
Aggregation pipelines
$matchand$sortfirst so they can use indexes; only the leading stages can.$sort+$limitadjacent lets the server keep only top N.$project/$unsetearly to shrink documents flowing through the pipeline.$lookupneeds an index on the foreign field (foreignField). A$lookupin a hot read path usually means the model should embed or extend the reference instead.$groupis blocking and memory-limited per stage (100 MB before spilling; spilling to disk is allowed by default since 6.0, but slow). Pre-filter aggressively.$facetruns sub-pipelines on the same input without index use inside them; keep the input small.- Materialize expensive reports with
$mergeinto a summary collection on a schedule rather than running them per request.
db.orders.aggregate([
{ $match: { tenantId: t, placedAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: { $dateTrunc: { date: "$placedAt", unit: "month" } },
revenue: { $sum: "$total" }, orders: { $sum: 1 } } },
{ $sort: { _id: 1 } },
]);
Transactions and consistency
- Single-document updates are atomic; prefer models that make the critical write a
single document (
$inc,$pushwith$slice,arrayFilters). - Multi-document transactions work on replica sets and sharded clusters; keep them short (default limit 60 s) and touching few documents. Needing them on every request is a modeling smell.
- Use
writeConcern: { w: "majority" }for writes that must survive failover, andreadConcern: "majority"where stale reads matter.
Gotchas
ObjectIdvs string IDs: a query with a string never matches an ObjectId field. Pick one type per field and validate it.$where, unanchored$regex, and$ne/$ninpredicates do not use indexes efficiently.- Updating a field inside a large array element with positional operators still rewrites the document; large hot documents create write contention.
- Mongoose
populate()is a client-side join issuing extra queries per path; watch for N+1 patterns. - Shard key choice is close to permanent (resharding exists but is costly). Choose a
high-cardinality key that matches the main query filter and avoids monotonically
increasing values (hash
_idor use a compound key). - Count with
countDocuments(filter);estimatedDocumentCount()ignores filters.
Output: model proposal
## Model: orders service
**Collections:** customers, orders (items embedded, max 200), products, reviews
**Key decisions**
- orders embeds items + product snapshot {sku, name, unitPrice}: read as a unit, prices are history.
- reviews referenced by productId; product embeds latest 5 (subset pattern).
**Indexes**
- orders { customerId: 1, placedAt: -1 } - history page (E,S)
- orders { status: 1, placedAt: 1 } partial status in [pending, paid] - fulfilment queue
**Validation:** $jsonSchema on orders and customers, action error.
**Explain results:** history query IXSCAN, keys/returned 1.0, 4 ms on 30M docs. About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.