Databases No. 199

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.

  • namemongodb-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

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.

~/.claude/skills/mongodb-modeling/SKILL.md

--- name: mongodb-modeling description: Designs MongoDB document models from access patterns - embedding vs referencing, bounded arrays, extended reference, subset, bucket,… license: MIT ---

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:

OperationTypeFrequencyFields filtered/sortedLatency target
Show order with itemsread2k/s_id20 ms
Customer order historyread200/scustomerId, sort placedAt desc50 ms
Add item to cartwrite500/s_id30 ms

3. Embed or reference

RelationshipDefaultWhy
1:few, always read together (addresses on a user)Embed arrayOne read, atomic update
1:many bounded (< ~100 items, line items of an order)EmbedOrder 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 oftenReferenceAvoid 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 attributesBounded arrays only
Data that changes on its own schedule (product price) used in history (order)Copy the snapshot into the orderIt 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

PatternUse whenShape
Extended referenceYou join to show a few fields of another entityStore { customerId, customerName } on the order; accept refreshing on rename
SubsetParent has many children but views show the latest NEmbed latest 10 reviews; full set in reviews collection
BucketTime series or high-volume childrenOne doc per device per hour with an array of readings, plus count; or use native time series collections
ComputedAggregates read far more than writtenMaintain commentCount, ratingSum with $inc on write
OutlierFew documents exceed normal sizeFlag hasOverflow: true and spill extra items to an overflow collection
PolymorphicVariants of one entity queried togetherSame collection, type discriminator, shared indexed fields
Schema versioningShape changes over timeschemaVersion field; app reads both, migrates lazily or with a background job
ArchiveOld data rarely readMove 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 SORT stage.
  • Sort direction matters for multi-field sorts: index {a: 1, b: -1} supports sort({a: 1, b: -1}) and sort({a: -1, b: 1}), not sort({a: 1, b: 1}).
  • Covered queries: project only indexed fields and exclude _id (unless indexed) so the plan shows PROJECTION_COVERED with totalDocsExamined: 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/$in on 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 with db.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:

FieldHealthy
winningPlan stagesIXSCAN -> FETCH (or covered), no COLLSCAN, no blocking SORT
totalKeysExamined / nReturnedclose to 1
totalDocsExamined / nReturnedclose to 1 (0 for covered queries)
executionTimeMilliswithin 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

  • $match and $sort first so they can use indexes; only the leading stages can. $sort + $limit adjacent lets the server keep only top N.
  • $project/$unset early to shrink documents flowing through the pipeline.
  • $lookup needs an index on the foreign field (foreignField). A $lookup in a hot read path usually means the model should embed or extend the reference instead.
  • $group is 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.
  • $facet runs sub-pipelines on the same input without index use inside them; keep the input small.
  • Materialize expensive reports with $merge into 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, $push with $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, and readConcern: "majority" where stale reads matter.

Gotchas

  • ObjectId vs 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/$nin predicates 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 _id or 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.

esc