MongoDB schema design starts from the workload, not from the entities. Write down the queries and writes the application performs and how often each one runs, then shape documents so that the most frequent operations touch one document, through one index, with no join. The rule that follows from this is "data that is accessed together is stored together", and most production schema problems are a violation of it in one direction or the other: too much packed into one document, or too little.
This article covers the principles, the hard limits that constrain them, and the anti-patterns that appear most often in real clusters, each with a way to detect it and a way to fix it.
Start from the workload
A relational design can be derived from the data alone: find the entities, normalize, and let the optimizer work out the joins later. A document design cannot, because the same data has several valid shapes and the right one depends on how it is read and written.
Before drawing any document, list the operations. A small online store might produce this:
Operation
Type
Frequency
Needs
Show product page
Read
2,000/s at peak
Product, price, stock flag, 5 newest reviews, rating average
List products in a category, sorted by price
Read
600/s
Name, price, thumbnail, rating average
Place order
Write
15/s
Must not lose data, must record prices at time of sale
Show "my orders"
Read
40/s
Order headers for one customer, newest first
Add review
Write
2/s
Review text, rating
Change product price
Write
A few per hour
Must be visible on the next page load
Monthly sales report
Read
Once a day
Can be slow, can run on a secondary
Three things in this table drive the design, and none of them is visible in an entity diagram:
The ratio of reads to writes for each piece of data. Reviews are read 1,000 times for every write, so it is worth doing extra work on write (maintaining a rating average and a short list of recent reviews inside the product) to make the read a single document fetch.
The 100 MB blocking-sort limit means your index doesn't provide the sort order. Build compound indexes with the ESR rule, order pipeline stages correctly, and know when allowDiskUse is fine.
What each read needs. The product page needs five reviews, not all of them. The category listing needs no reviews at all.
Which operations are allowed to be slow. The monthly report should not shape the schema. Design for the top three or four rows and accept that rare operations pay more.
Keep this table next to the schema and update it when features change. A schema that was right for last year's access pattern is the usual origin of the anti-patterns below.
Data that is accessed together is stored together
A document is the unit of I/O, of atomicity and of caching. Reading one document by _id or through an index costs one index traversal and one document fetch regardless of how many fields or embedded objects it has. A write to one document is atomic without a transaction. So when the product page needs the product, its rating summary and five reviews, the cheapest design puts all three in the product document.
The rule works in both directions. Data that is not accessed together should not be stored together, because the server reads and caches whole documents. A projection reduces what crosses the network, but unless the query is covered by an index the server still loads the complete document into the WiredTiger cache to produce the projected fields. A 200 KB document whose list view needs 300 bytes wastes cache on every read.
Relationships at a glance
The cardinality of a relationship, and whether it has an upper bound, narrows the choice quickly.
Relationship
Example
Usual model
One-to-one
User and notification preferences
Embed as a subdocument
One-to-few
Customer and shipping addresses
Embed as an array
One-to-many, bounded
Order and its line items
Embed, or reference if the children are queried on their own
One-to-many, large
Product and its reviews
Children in their own collection holding the parent _id, with a small subset copied into the parent
One-to-squillions
Host and its log lines
Children in their own collection holding the parent _id. Never an array on the parent
Many-to-many
Products and categories
Array of references on the side that has the smaller, bounded list
"Squillions" is the informal name for a child count with no practical ceiling. The test is not how many children exist today but whether anything stops the count from growing. An order has a natural ceiling on line items. A host does not have one on log lines.
These are enforced by the server and cannot be configured away.
Limit
Value
Design consequence
Maximum BSON document size
16 mebibytes
Any array that grows without bound will eventually fail writes
Nesting depth
100 levels
Rarely reached by hand, easily reached by recursive structures such as comment threads stored as nested replies
Indexes per collection
64
A collection that needs an index per attribute needs a different shape
Fields in a compound index
32
Not usually a constraint
Array fields per compound multikey index
At most one per document
You cannot index two parallel arrays together
Transaction lifetime
60 seconds by default (transactionLifetimeLimitSeconds)
Transactions suit short multi-document writes, not batch jobs
Time series document size
4 MB
Applies only to time series collections
Treat 16 MiB as a wall, not a target. Documents become expensive to read, cache and replicate long before they reach it. Check sizes with $bsonSize long before the limit matters:
This scans the collection, so run it on a secondary or off peak. db.products.stats().avgObjSize gives the average without a scan.
The anti-patterns
The MongoDB manual documents five schema anti-patterns: unbounded arrays, bloated documents, too many collections, unnecessary indexes and too many $lookup operations. Two more are not on that list but are just as common: splitting data that is always read together, and case-insensitive queries that no index can serve.
1. Unbounded arrays
An array that receives a new element for every event (reviews, comments, log entries, followers) grows until writes fail at 16 MiB. Long before that, every $push makes the document more expensive to read and replicate, and if the array is indexed, a multikey index holds one entry per element.
A maximum far above the average means a few documents are already in trouble. Fix it by moving the elements to their own collection keyed by the parent _id, and keeping only a capped subset in the parent. $push with $slice enforces the cap on every write:
A bloated document holds fields that are related but not read together: a product with its full description, specification sheet, change history and a base64 thumbnail, when the listing page needs the name and price. The working set (the documents and index pages in frequent use) stops fitting in RAM sooner than it should, and reads start going to disk.
Detect it by comparing avgObjSize from db.collection.stats() with the number of bytes the most frequent query returns. Fix it by splitting along access lines: the fields the hot path needs stay in the main document and the rest moves to a second collection that shares the same _id. Binary content belongs in object storage or GridFS, with a URL or id in the document.
3. A massive number of collections
Creating a collection per customer, per day or per device type looks tidy and scales badly. Every collection carries at least an _id index, and the storage engine has to manage each collection and each index separately. The manual's warning is that a large number of collections and their indexes strains replica set resources and degrades storage engine performance, even when most of them are idle.
Detect it with db.stats().collections, and look at names that contain dates or ids. Fix it by merging the collections into one and turning the name into a field (tenantId, day, deviceType) that leads the relevant indexes. Time-partitioned data usually belongs in a time series collection, with a TTL to expire old data in place of dropping collections.
4. Unnecessary indexes
Each index must be updated by every insert and delete, and by every update that changes its fields. It also takes disk and cache. Indexes accumulate because adding one is easy and nobody removes them. The two kinds to look for are indexes nothing uses and indexes that are a prefix of another index: { customerId: 1 } is redundant once { customerId: 1, createdAt: -1 } exists.
The counters are per node and start from the since time shown, so run it on every member, including secondaries that serve reads, and make sure the period covers a full business cycle. Then hide the index before dropping it. A hidden index is still maintained but the planner ignores it, so a mistake is undone in seconds without a rebuild:
db.orders.hideIndex("status_1")
// watch latency and slow query logs for a few days, then:
db.orders.dropIndex("status_1")
A hidden unique index still enforces uniqueness and a hidden TTL index still deletes documents, so hiding only tests the effect on query plans.
5. Separating data that is accessed together
This is the relational habit carried over: users, user_profiles, user_addresses and user_settings as four collections, when every request loads all four. Each page view becomes four round trips or a pipeline with three $lookup stages, and updating a profile and its settings together needs a transaction.
Detect it in application code: a function that always fetches the same set of documents by the same key is describing one document. Fix it by embedding the one-to-one and one-to-few parts into the parent. Migrate with an aggregation that ends in $merge, then switch reads, then drop the old collections.
6. Case-insensitive queries without a matching index
A login or search that must ignore case is often written as a regular expression:
db.users.find({ email: /^Ana@Example\.com$/i })
A case-sensitive prefix expression such as /^ana@/ lets the server scan only the matching range of an index. With the i option that optimization is lost, so on a large collection the query examines far more index keys than it returns. Confirm with explain("executionStats"): totalKeysExamined or totalDocsExamined far above nReturned is the signature.
The fix is an index with a case-insensitive collation, and a query that names the same collation:
Strength 1 or 2 makes the comparison case-insensitive. The query must specify the same collation as the index or the index is not used for the string comparison, and collation indexes do not help $regex at all. The alternative is to store a normalized copy (emailLower) and query that with a plain index, which is simpler when only one field needs it.
7. Overuse of $lookup
$lookup is a correct tool for occasional joins: reports, admin screens, a detail view that runs a few times a second. It becomes an anti-pattern when the most frequent read in the application depends on it. The join is evaluated for the documents that enter the stage, so the cost scales with how many reach it, and the manual warns that an equality $lookup without an index on the foreign field is likely to perform poorly. Sharded collections can be used as the from collection only from MongoDB 5.1.
Detect it by searching the slow query log or the profiler for pipelines containing $lookup and sorting by frequency, not by duration. A 15 ms lookup running 500 times a second matters more than a 3 second one running nightly. Fix it by copying the few fields the hot read needs into the document that is being read, and leave the reference in place for everything else.
Validate what you decide
A flexible schema means the server does not force a shape on you. It does not mean the shape is unimportant. Once the design is settled, add a $jsonSchema validator to each collection so that a bug or a script cannot write a string where the application expects a date. Validation costs a check per write and saves a class of production incidents that are very hard to clean up afterwards.
Common mistakes
Designing from the entity diagram. The result is one collection per table and $lookup everywhere.
Judging array growth by current data. A test dataset with 20 comments per post says nothing about the post that receives 80,000.
Relying on projection to fix large documents. It saves network bytes, not cache or disk reads.
Adding an index for every slow query without checking existing ones. Extend an index that shares the same prefix before creating a new one. The reasons an index may not help at all are covered in Why is my query still slow when the column has an index?.
Using transactions to compensate for a split schema. They work, but the manual is explicit that they cost more than single-document writes and are not a replacement for a schema that keeps related data together.
Never revisiting the design. Access patterns change. Re-run the workload table once or twice a year.
Checklist
List every operation with its type, frequency and the fields it needs. Rank by frequency.
Shape documents so each of the top operations reads one document through one index.
For every array, name the thing that bounds it. If nothing does, move the elements to their own collection and keep a capped subset.
Compare avgObjSize with what the hot queries return. Split documents whose cold fields dominate.
Count collections and look for names that contain ids or dates.
Run $indexStats on every node, hide unused and prefix-redundant indexes, then drop them.
Find $lookup stages and repeated same-key fetches on hot paths and replace them with embedded or copied fields.
Replace case-insensitive regular expressions with a collation index or a normalized field.
Add $jsonSchema validation once the shape is settled.
What the mongod server process does, how it differs from mongos and mongosh, the options and config file that control it, and how to start and stop it safely.