iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

$lookup (joins)

The $lookup stage joins documents from another collection — MongoDB’s answer to SQL joins. Combined with $unwind, $let, and pipeline form, it handles one-to-many, many-to-many, and graph traversals.

Simple, pipeline, unwind, performance

EXAMPLE
// 1) Simple lookup — join orders to customers
db.orders.aggregate([
    { $lookup: {
        from:         'customers',
        localField:   'customerId',
        foreignField: '_id',
        as:           'customer',
    } },
    { $unwind: { path: '$customer', preserveNullAndEmptyArrays: true } },
    { $project: { _id: 1, total: 1, status: 1, customerName: '$customer.name' } },
]);

// 'as' creates an ARRAY of matches; unwind flattens.

// 2) Pipeline form — richer join
db.orders.aggregate([
    { $lookup: {
        from: 'customers',
        let:  { custId: '$customerId', country: '$shippingAddress.country' },
        pipeline: [
            { $match: { $expr: {
                $and: [
                    { $eq: ['$_id', '$$custId'] },
                    { $eq: ['$country', '$$country'] },
                ],
            } } },
            { $project: { _id: 0, name: 1, email: 1 } },
        ],
        as: 'customer',
    } },
]);

// Multiple conditions, projection, sub-pipeline filters — much more powerful than localField/foreignField.

// 3) Many-to-many via array field
// posts.tags = ['tagA', 'tagB']; tags collection has documents with _id matching tag name
db.posts.aggregate([
    { $lookup: {
        from:         'tags',
        localField:   'tags',
        foreignField: '_id',
        as:           'tagDocs',
    } },
]);
// tagDocs becomes an array of full tag documents.

// 4) Aggregation inside lookup — count related, find latest
db.users.aggregate([
    { $lookup: {
        from: 'orders',
        let:  { uid: '$_id' },
        pipeline: [
            { $match: { $expr: { $eq: ['$userId', '$$uid'] } } },
            { $group: { _id: null, total: { $sum: '$amount' }, count: { $sum: 1 } } },
        ],
        as: 'orderStats',
    } },
    { $addFields: {
        totalSpent: { $ifNull: [{ $arrayElemAt: ['$orderStats.total', 0] }, 0] },
        orderCount: { $ifNull: [{ $arrayElemAt: ['$orderStats.count', 0] }, 0] },
    } },
    { $project: { orderStats: 0 } },
]);

// 5) Lookup + sort + limit (top N per parent)
db.users.aggregate([
    { $lookup: {
        from: 'orders',
        let:  { uid: '$_id' },
        pipeline: [
            { $match: { $expr: { $eq: ['$userId', '$$uid'] } } },
            { $sort:  { createdAt: -1 } },
            { $limit: 5 },
        ],
        as: 'recentOrders',
    } },
]);

// 6) Nested lookups
db.orders.aggregate([
    { $lookup: { from: 'customers', localField: 'customerId', foreignField: '_id', as: 'customer' } },
    { $unwind: '$customer' },
    { $lookup: { from: 'addresses', localField: 'customer.addressId', foreignField: '_id', as: 'address' } },
    { $unwind: '$address' },
]);

// 7) $graphLookup — recursive joins (org charts, comments)
db.employees.aggregate([
    { $match: { name: 'CEO' } },
    { $graphLookup: {
        from:             'employees',
        startWith:        '$_id',
        connectFromField: '_id',
        connectToField:   'managerId',
        as:               'reports',
        maxDepth:         5,
        depthField:       'level',
    } },
]);

// 8) Performance considerations
// • Index the foreignField (or matched fields in pipeline form) — without it, FULL COLLECTION SCAN per parent
// • Restrict the foreign collection with a $match early in the pipeline
// • Project only fields you need — return less data
// • For very large fanouts, consider denormalising or computing in app code
// • Watch the result size — joined documents can blow past 16 MB document limit

// 9) Sharded collections
// $lookup against a sharded foreign collection works but may be slower; consider the routing implications
// $lookup from a sharded source — restrictions apply pre-MongoDB 5.0; check docs for your version

// 10) Lookup + project to flatten
db.orders.aggregate([
    { $lookup: { from: 'customers', localField: 'customerId', foreignField: '_id', as: 'customer' } },
    { $project: {
        _id: 1,
        total: 1,
        customerName:  { $arrayElemAt: ['$customer.name', 0] },
        customerEmail: { $arrayElemAt: ['$customer.email', 0] },
    } },
]);

// 11) When NOT to use $lookup
// • Tight read paths needing low latency — denormalise common fields
// • Frequently-joined data — duplicate critical fields onto the source
// • Cross-shard joins — consider data redesign
// • Real-time chat / streaming — use change streams + app logic

// 12) Common bugs
// • Forgot $unwind after lookup → field is array even if 0 or 1 match
// • Used localField with EMBEDDED fields — only top-level paths supported; project first
// • foreignField type mismatch (string vs ObjectId) → no matches
// • No index on foreignField → 10× slower at scale; create index explicitly
// • Joined collection > 16 MB → operation aborts; filter inside the pipeline
// • $lookup as the FIRST stage → very expensive; $match first, then $lookup
// • preserveNullAndEmptyArrays missing → unwind drops rows without matches
// • Trying to join across databases — $lookup only works within one DB
// • Stale results — joins are computed live; cache if needed

Why it matters

Use $lookup with the pipeline form for richer joins and projection control, $unwind with preserveNullAndEmptyArrays to flatten safely, and $graphLookup for recursive trees. Always index the foreign field, filter the parent with $match first, and consider denormalising hot fields when join performance matters.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// SQL-style join
{ $lookup: {
    from: 'customers',
    localField: 'customer_id',
    foreignField: '_id',
    as: 'customer',
} }
Try it Yourself »

Discussion

Loading…