pattern extended reference
**Copy frequently-accessed fields from referenced documents into the parent.** If you always display author name with articles, embed it. This eliminates $lookup for common queries while keeping the full data normalized—best of both worlds.
Overview
Use Extended Reference Pattern
Copy frequently-accessed fields from referenced documents into the parent. If you always display author name with articles, embed it. This eliminates $lookup for common queries while keeping the full data normalized—best of both worlds.
Incorrect (always $lookup for display data):
// Order references customer by ID only
{
_id: "order123",
customerId: "cust456", // Customer reference by ID only
items: [...],
total: 299.99
}
// Every order list/display requires $lookup
db.orders.aggregate([
{ $match: { status: "pending" } },
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}},
{ $unwind: "$customer" }
])
// Repeated joins add avoidable work for a common list view
Correct (extended reference):
Embed frequently-needed customer fields directly in the order document: include a customer subdocument with _id (kept as a reference for full lookups), name, and email. The order list query returns customer display data without $lookup. Full customer data is still available via a targeted read to the customers collection when needed.
Keeping cached data in sync:
When the source field changes (e.g. customer name), update the source collection first, then update cached copies in the orders collection using updateMany on the embedded reference _id. This can be done synchronously or asynchronously via Change Streams / background jobs. For data that changes more often, add a cachedAt timestamp to the embedded subdocument so the application can refresh on read when the cache exceeds a staleness threshold.
What to cache (extend):
| Cache | Don't Cache |
|---|---|
| Display name, avatar | Full bio, description |
| Status, type | Sensitive PII |
| Slowly-changing data | Real-time values (balance, inventory) |
| Fields used in sorting/filtering | Large binary data |
Alternative: Hybrid pattern with cache expiry:
Keep both a bare reference (customerId) and an optional cache subdocument (customerCache) with name, email, and cachedAt. On read, if the cache is missing or older than a threshold (e.g. one day), refresh it from the customers collection and write the updated cache back to the order.
When NOT to use this pattern:
- Frequently-changing data: If customer name changes daily, update overhead exceeds $lookup cost.
- Large cached payloads: Don't embed 50KB of author bio in every article.
- Sensitive data segregation: Don't copy PII into collections with different access controls.
- Writes >> Reads: If writes greatly outnumber reads, caching adds overhead.
Verify with
// Find $lookup-heavy aggregations in profile
db.setProfilingLevel(1, { slowms: 20 }) // Disable afterwards
db.system.profile.find({
"command.aggregate": { $exists: true },
"command.pipeline.$lookup": {
$exists: true
}
}).sort({ millis: -1 }).limit(10)
// Check how often lookups hit same collections
db.system.profile.aggregate([
{ $match: { "command.pipeline.$lookup": { $exists: true } } },
{ $project: { pipeline: "$command.pipeline" } },
{ $unwind: "$pipeline" },
{ $project: { lookup: { $getField: { field: { $literal: '$lookup' }, input: '$pipeline' } } } },
{ $match: { "lookup": { $exists: true } } },
{ $group: { _id: "$lookup.from", count: { $sum: 1 } } }
])
// High count = candidate for extended reference
Reference: Reduce $lookup Operations