MongoDB Schema Design skill for Codex
Model documents from the queries they serve. Codex starts a MongoDB model from an access-pattern table, not from relational tables, then decides embedding versus referencing per relationship using cardinality, growth and update frequency. It applies patterns like subset, computed and extended reference only where a query needs them, caps arrays on write, adds a server-side $jsonSchema validator and orders compound index keys equality, sort, range.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- mongodb-schema-design Installs to ~/.agents/skills/mongodb-schema-design/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install mongodb-schema-design The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project mongodb-schema-design Installs to .agents/skills/mongodb-schema-design/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/mongodb-schema-design.zipunzip -o mongodb-schema-design.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) mongodb-schema-design PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $mongodb-schema-design, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs MongoDB document models from the application's access patterns - embedding versus referencing, one-to-few, one-to-many and many-to-many shapes, the extended reference, subset, computed, bucket, outlier, polymorphic and schema versioning patterns, $jsonSchema validation, index plans that follow the ESR rule, and shard key choice. Flags unbounded arrays, 16 MB document risk, and data that needs multi-document transactions. Use when modeling a new MongoDB or Mongoose collection, reviewing an existing schema, migrating relational tables to MongoDB, or when documents grow too large or reads need many round trips.
Use it when
- Modeling new MongoDB or Mongoose collections
- Documents grow without bound or approach the 16 MB limit
- Pages need many queries or $lookup stages to render
- Adding schema validation or choosing a shard key
Use something else when
- Writing or tuning a specific aggregation pipeline: use mongodb-aggregation
- The data is relational with heavy cross-entity transactions: use postgres-schema-design
How to check the result
Every access-pattern query explains as IXSCAN with documents examined close to documents returned, the largest realistic document stays far under 16 MB, and the validator rejects a bad test insert.
The mistake it prevents
Embedding an array that grows with user activity, which bloats documents, rewrites them on every push and multiplies index entries.
Done well means: each collection’s shape is justified by a written list of the queries it serves, data read together is stored together, nothing grows without bound inside one document, the database enforces the shape with a validator, and every frequent query has an index that matches its equality, sort and range order.
Workflow
- Read
AGENTS.md; find the driver or ODM (Node driver, Mongoose, PyMongo/Motor, Spring Data, etc.) and the MongoDB server version (db.version()). - Write the access-pattern table (below) with the user before proposing documents.
- Sketch documents per entity using the embed/reference rules.
- Apply patterns only where a row in the table calls for them.
- Add a
$jsonSchemavalidator and the index plan. - Check growth: max array lengths, document size at the 99th percentile in a year.
- Verify with explain plans on realistic sample data.
Access-pattern table
| # | Operation | Frequency | Filter | Sort | Returns | Latency target |
|---|---|---|---|---|---|---|
| 1 | Product page | 2k/s | slug | - | product + 10 latest reviews + seller name | < 20 ms |
| 2 | Seller dashboard | 5/s | sellerId, status | updatedAt desc | product summaries | < 100 ms |
| 3 | Add review | 50/s | productId | - | write | - |
Writes and their frequency matter as much as reads: a field copied into 1M documents is cheap to read and expensive to change.
Embed or reference
| Signal | Embed | Reference |
|---|---|---|
| Read together on the hot path | Yes | Only if size/growth forbids embedding |
| Cardinality of the child side | Few, bounded (tens to low hundreds) | Many or unbounded |
| Child has its own lifecycle or is queried alone | No | Yes |
| Child is shared by many parents | No (duplication) | Yes, or extended reference |
| Child changes often, parent read often | Careful: rewrites the whole parent | Yes |
| Needs atomic update with parent | Yes: single-document writes are atomic | Needs a transaction |
Shapes:
- One-to-few (addresses on a user): embed an array.
- One-to-many (orders of a customer): reference from the child (
customerIdon the order) and index it. - One-to-squillions (log events of a host): child references parent; never an array on the parent.
- Many-to-many (students and courses): arrays of ids on the side that is queried, kept bounded; or a link collection when both sides are large.
Patterns, and when each earns its place
| Pattern | Use when | Shape |
|---|---|---|
| Extended reference | A read needs a few fields of a referenced document | Copy {_id, name} of the seller into the product; update copies when the source changes |
| Subset | Parent has a large child list but the page shows the latest N | Embed the newest 10 reviews; full list in reviews collection |
| Computed | An aggregate is read far more often than its inputs change | Store reviewCount, ratingAvg; update with $inc on write or a scheduled job |
| Bucket | Time series or high-volume children | One document per sensor per hour with an array of readings (or use a native time series collection) |
| Outlier | A few parents have far more children than the rest | Normal embedding plus hasOverflow: true and overflow documents |
| Polymorphic | Entities share most fields and are queried together | One collection with a type discriminator |
| Attribute | Many sparse, similar fields that need indexing | attrs: [{k: "color", v: "red"}] with an index on {"attrs.k": 1, "attrs.v": 1} |
| Schema versioning | Shape must change without a big-bang migration | schemaVersion field; code reads all live versions and upgrades on write |
| Archive | Old data rarely read | Move to an archive collection or Online Archive by date |
For time series, prefer a time series collection (db.createCollection("metrics", { timeseries: { timeField: "ts", metaField: "meta", granularity: "minutes" } })) over a
hand-rolled bucket unless you need update patterns it does not support.
Example: product with subset and extended reference
{
_id: ObjectId("..."),
slug: "trail-runner-2",
name: "Trail Runner 2",
priceCents: 12900,
seller: { _id: ObjectId("..."), name: "North Gear" }, // extended reference
ratingAvg: 4.6, reviewCount: 1832, // computed
recentReviews: [ // subset, capped at 10
{ _id: ObjectId("..."), userName: "ana", stars: 5, text: "...", at: ISODate("...") }
],
schemaVersion: 2,
updatedAt: ISODate("...")
}
Keep the subset capped on write:
db.products.updateOne(
{ _id: productId },
{
$push: { recentReviews: { $each: [review], $sort: { at: -1 }, $slice: 10 } },
$inc: { reviewCount: 1 }
}
);
Validation
db.runCommand({
collMod: "products",
validator: { $jsonSchema: {
bsonType: "object",
required: ["slug", "name", "priceCents", "seller", "schemaVersion"],
properties: {
slug: { bsonType: "string", pattern: "^[a-z0-9-]+$" },
priceCents: { bsonType: ["int", "long"], minimum: 0 },
recentReviews: { bsonType: "array", maxItems: 10 },
schemaVersion: { bsonType: "int" }
}
}},
validationLevel: "moderate", // existing invalid docs are not blocked on unrelated updates
validationAction: "error"
});
Start with validationAction: "warn" on an existing collection, check logs for
violations, fix data, then switch to "error". Mongoose schemas validate only writes made
through Mongoose; the server validator covers every client.
Indexes: the ESR rule
Order compound index keys as Equality, then Sort, then Range:
// Query: { sellerId: X, status: "active", priceCents: { $lt: 5000 } } sort { updatedAt: -1 }
db.products.createIndex({ sellerId: 1, status: 1, updatedAt: -1, priceCents: 1 });
db.products.createIndex({ slug: 1 }, { unique: true });
db.orders.createIndex({ customerId: 1, createdAt: -1 });
- Every reference field used for lookups needs an index; MongoDB never creates them for you.
- Partial indexes (
partialFilterExpression) for queries on a subset, such as active items. - Multikey indexes on arrays multiply index entries by array length; another reason to bound arrays. A compound index can include at most one array field per document.
- Limit: 64 indexes per collection; each one slows writes. Remove unused ones after
checking
$indexStatsover a full business cycle. - Building indexes on large production collections needs confirmation and a quiet window.
Sharding (only if needed)
Shard key = a field in almost every query filter, high cardinality, and writes spread
evenly. Monotonic keys (ObjectId, timestamps) alone create a hot shard; use a compound key
({ tenantId: 1, _id: 1 }) or hashed key. Changing a shard key later (reshardCollection)
is expensive; decide with the access-pattern table.
Anti-patterns to flag in review
- Arrays that grow with usage (comments, events, followers) on a parent document.
- Documents approaching the 16 MB BSON limit, or routinely above a few hundred KB.
- Relational normalization copied one-to-one: a page needing five
$lookupstages. - Duplicated fields with no plan for updating copies.
- Case-insensitive lookups without a collation index or a normalized field.
- Storing money as double; use integer minor units or
Decimal128. - Relying on multi-document transactions for hot paths; reshape so one document holds what must change atomically.
Verification
- For each access-pattern row, run the query with
db.collection.find(filter).sort(s).explain("executionStats")on sample data:IXSCAN,totalDocsExaminedclose tonReturned, noSORTstage for indexed sorts. Object.bsonsize(doc)on the largest realistic document stays well below 16 MB.- Validator rejects a deliberately bad insert in a test.
- Mongoose or ODM models match the validator (no field the server would reject).
Output
## Model: <collection>
**Serves access patterns:** #1, #2
**Document shape:** <example JSON>
**Patterns used:** subset (recentReviews <= 10), extended reference (seller.name)
**Copies to maintain:** seller.name <- sellers.name, updated by <job/trigger>
**Indexes:** { sellerId: 1, status: 1, updatedAt: -1 } for #2
**Growth bounds:** <array caps, size estimate>
**Validator:** $jsonSchema, action error|warn What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "MongoDB Schema Design"
short_description: "Model documents from the queries they serve"
default_prompt: "$mongodb-schema-design Design the MongoDB collections for products, sellers and reviews in our marketplace" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.