{"slug": "lookup-join-strategies-understanding-the-trade-offs-with-flexible-documents", "title": "$lookup join strategies: understanding the trade-offs with flexible documents", "summary": "A developer tested MongoDB $lookup join strategies against DocumentDB for PostgreSQL, an open-source extension implementing the MongoDB API on PostgreSQL. The analysis reveals that while document databases minimize joins through embedding, flexible field semantics like arrays restrict join algorithms, making relational databases more efficient for joins when they are necessary. The developer ran tests with 5 million portfolio documents and 5 FX rate documents, finding that DocumentDB's PostgreSQL optimizer can leverage scalar columns for better join performance compared to MongoDB's limited join strategies.", "body_md": "In a [previous post](https://dev.to/franckpachot/nested-loop-and-hash-join-for-mongodb-lookup-259d), I explored how MongoDB chooses between nested loop, indexed loop, and hash join strategies for `$lookup`\n\n. Here, I examine what occurs when `$lookup`\n\nruns on [DocumentDB for PostgreSQL](https://github.com/documentdb/documentdb)—an open-source extension implementing the MongoDB API on PostgreSQL.\n\n**The document model minimizes the need for joins by embedding related data directly within documents. However, when a join is necessary — such as for reference data that updates independently, many-to-many relationships, or dimensional lookups — the flexibility of embedding can complicate join optimization.**\n\nThe goal isn't just to identify \"which database is faster\"—it's to understand why their behaviors differ, the trade-offs involved, and the options when join performance matters.\n\nRelational databases tend to perform more joins because normalized schemas require them, but they also optimize joins more effectively thanks to scalar, well-typed columns. In contrast, document databases perform fewer joins thanks to embedding, but when they do, flexible field semantics—such as arrays—restrict the available join algorithms.\n\nI've run all tests in Docker containers with default settings on the same machine. The timings are indicative, not benchmarks — they illustrate the relative cost of different approaches, not absolute performance under production conditions (caching, concurrency, hardware, and tuning would all change the numbers).\n\nIn a document database, you'd typically embed related data to avoid joins. But some data doesn't embed well:\n\n`rate_to_usd`\n\ninside each portfolio document, you'd need to update millions of documents every time a rate moves.This is a classic case where a `$lookup`\n\njoin makes sense: a large fact collection (portfolios) joined to a small, frequently-updated reference collection (fxRates). The document model can't avoid this join without accepting stale embedded rates.\n\nI created two collections:\n\n`portfolios`\n\n: 5 million documents with a `currency`\n\nfield (5 distinct values)`fxRates`\n\n: 5 documents mapping each currency to its USD exchange rateI used `mongosh`\n\nto create and load the collection with the following commands:\n\n``` js\ndb.portfolios.drop();\ndb.fxRates.drop();\n\nconst currencies = [\"USD\", \"EUR\", \"CHF\", \"GBP\", \"JPY\"];\ncurrencies.forEach(cur => {\n  db.fxRates.insertOne({\n    currency: cur,\n    rate_to_usd: Math.random() * (1.5 - 0.5) + 0.5,\n    last_updated: new Date()\n  });\n});\n\nconst totalPortfolios = 5e6;\nlet bulk = [];\nfor (let i = 1; i <= totalPortfolios; i++) {\n  const currency = currencies[Math.floor(Math.random() * currencies.length)];\n  bulk.push({\n    portfolioId: i,\n    clientId: Math.floor(Math.random() * 10000),\n    valuation: Math.round(Math.random() * 1_000_000),\n    currency: currency,\n    asOfDate: new Date()\n  });\n  if (bulk.length === 10000) {\n    db.portfolios.insertMany(bulk);\n    bulk = [];\n  }\n}\nif (bulk.length > 0) db.portfolios.insertMany(bulk);\n\ndb.fxRates.createIndex({ currency: 1 }, { unique: true });\n```\n\nThe index on a five-document collection is not strictly necessary, but it's good practice and protects my lookup table from duplicates.\n\n`$lookup`\n\nThis query fetches all portfolios, retrieves the foreign exchange rate for each currency, and converts the valuation to USD.\n\n```\ndb.portfolios.aggregate([\n  {$lookup: {\n    from: \"fxRates\",\n    localField: \"currency\",\n    foreignField: \"currency\",\n    as: \"fx\"\n  }},\n  {$unwind: \"$fx\"},\n  {$project: {\n    portfolioId: 1, valuation: 1, currency: 1,\n    rate_to_usd: \"$fx.rate_to_usd\",\n    valuation_usd: {$multiply: [\"$valuation\", \"$fx.rate_to_usd\"]}\n  }}\n])\n```\n\nMongoDB's `$lookup`\n\ncombined with `$unwind`\n\nbehaves like a LEFT OUTER JOIN followed by filtering out non-matching rows.\n\nIn a relational database, `portfolios.currency`\n\nis a `VARCHAR`\n\ncolumn. The optimizer knows it's a single scalar value per row. It can extract it, hash it, sort it, or probe an index with it — all with well-defined operators.\n\nIn a document database, `currency`\n\nmight be:\n\n`\"USD\"`\n\n`[\"USD\", \"EUR\"]`\n\nMongoDB's `$lookup`\n\ncompatibility requires the following behavior:\n\n`localField`\n\nis an array `[\"USD\", \"EUR\"]`\n\n, it matches any foreign document where `foreignField`\n\nequals `\"USD\"`\n\nOR `\"EUR\"`\n\n(or contains either, if it's also an array).This means that the join condition is not always a simple equality `a = b`\n\n, but may involve “any element matches” semantics evaluated at runtime. Instead, the matching logic must evaluate each document's field at runtime, determine whether it's a scalar or an array, and match accordingly.\n\nThe safest general approach is a **lateral join** — executing the inner query for each outer document and passing the current document's field value into the matching function. This is what both MongoDB and DocumentDB for PostgreSQL do.\n\nI use the DocumentDB API in a SQL query rather than the MongoDB-compatible endpoint to view the PostgreSQL execution plan.\n\n```\nEXPLAIN (ANALYZE ON, BUFFERS ON, COSTS ON, VERBOSE ON)\nSELECT document\nFROM bson_aggregation_pipeline('test',\n'{\n  \"aggregate\": \"portfolios\",\n  \"pipeline\": [\n    {\n      \"$lookup\": {\n        \"from\": \"fxRates\",\n        \"localField\": \"currency\",\n        \"foreignField\": \"currency\",\n        \"as\": \"fx\"\n      }\n    },\n    {\n      \"$unwind\": \"$fx\"\n    },\n    {\n      \"$project\": {\n        \"portfolioId\": 1,\n        \"valuation\": 1,\n        \"currency\": 1,\n        \"rate_to_usd\": \"$fx.rate_to_usd\",\n        \"valuation_usd\": {\n          \"$multiply\": [\n            \"$valuation\",\n            \"$fx.rate_to_usd\"\n          ]\n        }\n      }\n    }\n  ],\n  \"cursor\": {}\n}');\n```\n\nSince I joined a large collection with a small one and require all documents from both, I would anticipate a hash join. Instead, it uses a nested loop join:\n\n``` php\nNested Loop  (actual time=579..64792 rows=5000000 loops=1)\n  ->  Seq Scan on documents_11 collection  (rows=5000000 loops=1)\n  ->  Seq Scan on documents_10 collection_0_1  (rows=1 loops=5000000)\n        Filter: bson_dollar_lookup_join_filter(...)\n        Rows Removed by Filter: 4\nExecution Time: 87750 ms\n```\n\nThe fxRates table (5 rows, fitting in a single 8kB block) is scanned 5 million times. PostgreSQL's cost-based optimizer knows the table is tiny and fits in cache, so a sequential scan is the right choice over an index scan — but the scan is still executed 5 million times because of the LATERAL pattern. The filter function `bson_dollar_lookup_join_filter`\n\nis evaluated 25 million times. This function handles array semantics — it extracts the field from the outer document, determines whether it's scalar or an array, and checks for matches in the inner document.\n\nBecause the inner side is marked as LATERAL, it depends on the current outer row. This prevents PostgreSQL from evaluating both sides independently, which is required for hash or merge joins. As a result, only a nested loop strategy is possible.\n\nIn MongoDB, the equivalent behavior is the `IndexedLoopJoin`\n\nstrategy: for each outer document, probe the index on the foreign field. The algorithm and per-document cost are the same.\n\nMongoDB 8.0 can use hash join for `$lookup`\n\nwhen `allowDiskUse: true`\n\n, no compatible index on the foreign field, the foreign collection is small, and the SBE engine is active. Under these conditions, MongoDB builds an in-memory hash table from the foreign collection, correctly handling array semantics by storing per-element entries.\n\nIn tests with 5M portfolios and 5 fxRates, MongoDB's native `HashJoin`\n\nfinished in ~14 seconds — the fastest of my tests. Without tweaks, it took 170 seconds — the worst.\n\nTo achieve 14 seconds, I dropped the index on the foreign field, enabled `allowDiskUse`\n\n, and set `internalQueryFrameworkControl`\n\nto `trySbeEngine`\n\n. The default `trySbeRestricted`\n\nmode doesn't push the `$lookup`\n\nand `$unwind`\n\nto SBE, since the optimization depends on feature flags that aren't enabled in this mode. With `trySbeEngine`\n\n, SBE handles the pipeline, using HashJoin:\n\n```\n// Setup for hash join\ndb.adminCommand({setParameter: 1, internalQueryFrameworkControl: \"trySbeEngine\"});\ndb.fxRates.dropIndex(\"currency_1\");\n\n// The query (same as all other tests)\ndb.portfolios.aggregate([\n  {$lookup: {from: \"fxRates\", localField: \"currency\", foreignField: \"currency\", as: \"fx\"}},\n  {$unwind: \"$fx\"},\n  {$project: {portfolioId: 1, valuation: 1, currency: 1, rate_to_usd: \"$fx.rate_to_usd\", valuation_usd: {$multiply: [\"$valuation\", \"$fx.rate_to_usd\"]}}}\n], {allowDiskUse: true}).explain(\"executionStats\");\n\n// Restore\ndb.fxRates.createIndex({currency: 1}, {unique: true});\ndb.adminCommand({setParameter: 1, internalQueryFrameworkControl: \"trySbeRestricted\"});\n```\n\nDocumentDB for PostgreSQL doesn't currently implement this optimization — it relies on PostgreSQL's native join strategies, which don't understand BSON array semantics. Under normal conditions, both MongoDB and DocumentDB use a Nested Loop join.\n\n`_id`\n\nas Join Key (~71s)\nThe documentDB extension has a [special case](https://github.com/documentdb/documentdb/blob/v0.113-0/pg_documentdb/src/aggregation/bson_aggregation_nested_pipeline.c#L2134) when `foreignField`\n\nis `_id`\n\n— it uses direct `object_id`\n\nequality:\n\n```\n// Reshape fxRates to use currency as _id\ndb.fxRates.drop();\ncurrencies.forEach(cur => {\n  db.fxRates.insertOne({\n    _id: cur,\n    rate_to_usd: Math.random() * (1.5 - 0.5) + 0.5,\n    last_updated: new Date()\n  });\n});\n\ndb.portfolios.aggregate([\n  {$lookup: {from: \"fxRates\", localField: \"currency\", foreignField: \"_id\", as: \"fx\"}},\n  {$unwind: \"$fx\"},\n  {$project: {portfolioId:1, valuation:1, currency:1,\n              rate_to_usd:\"$fx.rate_to_usd\",\n              valuation_usd:{$multiply:[\"$valuation\",\"$fx.rate_to_usd\"]}}}\n])\n```\n\nIt uses an index scan with the join condition applied as an `Index Cond`\n\n, which is more efficient than a sequential scan with a `Filter`\n\n. It's slightly faster, taking 71 seconds instead of 88 seconds, yet it remains a nested loop with 5 million iterations:\n\n``` php\nNested Loop  (actual time=17..48170 rows=5000000 loops=1)\n  ->  Seq Scan on documents_11 collection  (rows=5000000 loops=1)\n  ->  Index Scan using _id_ on documents_12  (rows=1 loops=5000000)\n        Index Cond: (object_id = ANY (bson_dollar_lookup_extract_filter_array(...)))\nExecution Time: 70578 ms\n```\n\nThis is the same as MongoDB's `IndexedLoopJoin`\n\n— the `_id`\n\nfield is guaranteed to be scalar, so the extension can use a direct equality lookup on the primary key. However, it doesn't change the join strategy.\n\n`$lookup`\n\n+ `$filter`\n\n(~68s)\nA minor enhancement involves reading all fxRates at once, using an empty pipeline and no join condition, attaching the data as an array, and then filtering locally:\n\n```\ndb.portfolios.aggregate([\n  {$lookup: {from: \"fxRates\", pipeline: [], as: \"allFx\"}},\n  {$addFields: {\n    fx: {$arrayElemAt: [{$filter: {\n      input: \"$allFx\", as: \"r\",\n      cond: {$eq: [\"$$r.currency\", \"$currency\"]}\n    }}, 0]}\n  }},\n  {$project: {portfolioId:1, valuation:1, currency:1,\n              rate_to_usd:\"$fx.rate_to_usd\",\n              valuation_usd:{$multiply:[\"$valuation\",\"$fx.rate_to_usd\"]}}}\n])\n```\n\nThe execution plan shows a Nested Loop with a single loop:\n\n``` php\nNested Loop  (actual time=17..20177 rows=5000000 loops=1)\n  ->  Aggregate  (rows=1 loops=1)          -- reads fxRates ONCE\n  ->  Seq Scan on documents_11  (rows=5000000 loops=1)\nExecution Time: 67905 ms  (of which ~48s is $addFields+$project)\n```\n\nThe join itself is fast — fxRates are aggregated once into a single array. But the per-document `$filter`\n\n+ `$arrayElemAt`\n\nevaluates BSON expressions 5 million times. We traded \"nested loop probe\" for \"per-row array scan in BSON space\".\n\nThis is conceptually similar to the \"nested loop with materialization\" approach from the [previous MongoDB article](https://dev.to/franckpachot/nested-loop-and-hash-join-for-mongodb-lookup-259d) — reading the lookup collection once, but matching per-document in the projection.\n\n`$lookup`\n\n— No Help\nUsing `$lookup`\n\nwith `pipeline`\n\nand `let`\n\ndoesn't enhance performance:\n\n```\n  {$lookup: {\n    from: \"fxRates\",\n    let: { cur: \"$currency\" },\n    pipeline: [\n      {$match: {$expr: {$eq: [\"$currency\", \"$$cur\"]}}}\n    ],\n    as: \"fx\"\n  }},\n  {$unwind: \"$fx\"},\n```\n\nThe extension still creates a LATERAL join (all code paths set `rightTree->lateral = true`\n\n), and it introduces additional overhead due to variable resolution.\n\nWith the MongoDB-compatible API, no solution significantly improves the efficiency of the join. But on DocumentDB, the power of SQL opens new possibilities.\n\nSince DocumentDB stores data in standard PostgreSQL tables, we can query the same collections with SQL—within the same transaction and with full ACID guarantees. The trade-off is that we lose flexible-document join semantics and assume scalar join keys.\n\nThe `bson`\n\ntype has a hash operator class (`bson_hash_ops`\n\n) used for `GROUP BY`\n\nand `DISTINCT`\n\n. But the `=`\n\n[operator](https://github.com/documentdb/documentdb/blob/v0.113-0/pg_documentdb_core/sql/operators/bson_btree_operators--0.10-0.sql) doesn't declare hash join support — it's [missing](https://github.com/documentdb/documentdb/blob/v0.113-0/pg_documentdb_core/sql/schema/bson_hash_operator_class--0.15-0.sql) `HASHES`\n\nand `MERGES`\n\nproperties. This is likely intentional, since `bson = bson`\n\ncomparison on full documents has different semantics than field-level equality. But for my investigation (comparing extracted scalar field values), it would work:\n\n```\n-- Requires superuser — this is a hack, not a supported configuration\n-- If DocumentDB enables this in the future, it will be part of the extension\nALTER OPERATOR documentdb_core.= (documentdb_core.bson, documentdb_core.bson)\n  SET (COMMUTATOR = OPERATOR(documentdb_core.=), HASHES, MERGES);\n```\n\nWithout this, PostgreSQL cannot execute hash join for `bson = bson`\n\nconditions, even in custom SQL. However, note that the SQL hash join method, enabled by this hack, does not replicate MongoDB's \"any element matches\" behavior when joined fields include arrays.\n\nTo utilize a SQL join, I first query the two collections within two common table expressions in the WITH clause, then join them in the main query:\n\n```\nWITH portfolios AS (\n  SELECT document FROM documentdb_api.collection('test', 'portfolios')\n),\nfxRates AS (\n  SELECT document FROM documentdb_api.collection('test', 'fxRates')\n)\nSELECT documentdb_api_internal.bson_dollar_project(\n  documentdb_api_internal.bson_dollar_merge_documents_at_path(\n    p.document, f.document, 'fx'),\n  '{ \"portfolioId\" : 1, \"valuation\" : 1, \"currency\" : 1,\n     \"rate_to_usd\" : \"$fx.rate_to_usd\",\n     \"valuation_usd\" : { \"$multiply\" : [\"$valuation\", \"$fx.rate_to_usd\"] } }'::bson,\n  '{}'::bson\n)\nFROM portfolios p\nJOIN fxRates f\n  ON documentdb_api_catalog.bson_expression_get(\n       p.document, '{\"\": \"$currency\"}'::bson, true)\n   = documentdb_api_catalog.bson_expression_get(\n       f.document, '{\"\": \"$currency\"}'::bson, true);\n```\n\nWith this query and the operator tweak enabling hash join, I have the following execution plan:\n\n```\nHash Join  (actual time=7.4..34018 rows=5000000 loops=1)\n  Hash Cond: (bson_expression_get(documents_11.document, '{\"\":\"$currency\"}'...)\n            = bson_expression_get(documents_10.document, '{\"\":\"$currency\"}'...))\n  ->  Seq Scan on documents_11  (rows=5000000 loops=1)\n  ->  Hash  (rows=5 loops=1)\n        Buckets: 1024  Batches: 1  Memory Usage: 9kB\n        ->  Seq Scan on documents_10  (rows=5 loops=1)\nExecution Time: 38664 ms\n```\n\nPostgreSQL creates a small 5-row hash table (9 kB) and probes it once per portfolio. It makes a single pass over both collections. Most of the remaining time is spent calling `bson_expression_get`\n\n5 million times to retrieve the join key, along with `bson_dollar_merge_documents_at_path`\n\nand `bson_dollar_project`\n\nto generate the final output.\n\nIn the end, this query is only about twice as fast. It requires a complex workaround, breaks document semantics, and still spends most of its time evaluating BSON expressions.\n\nBelow is a summary of my experiments, run in Docker containers with default configurations, involving 5 million portfolios, 5 fxRates, and a unique index on `fxRates.currency`\n\n:\n\n| Approach | MongoDB | DocumentDB | Strategy |\n|---|---|---|---|\n`$lookup` localField/foreignField |\n~170s | ~88s | Nested Loop (lateral index/filter) |\n`$lookup` with `foreignField: \"_id\"`\n|\n~155s | ~71s | Nested Loop (index probe) |\nUncorrelated `$lookup` + `$filter`\n|\n~22s | ~68s | Materialize once + per-doc filter |\nSQL CTE + Hash Join (operator tweak) |\n— | ~39s |\nHash Join (forced) |\n`HashJoin` (SBE, internal tweak) |\n~14s |\n— | Hash Join (forced) |\n\nMongoDB's native `HashJoin`\n\nvia the Slot-Based Execution engine is fastest, handling hash table build/probe natively with per-element array support and avoiding BSON field extraction overhead, but will not be used without configuration tweaks. The DocumentDB SQL escape hatch uses PostgreSQL's optimizer for the same join strategy but incurs overhead with `bson_expression_get`\n\non each row.\n\nThe other solutions are compatible with standard configurations and use appropriate data models and query code. Remember that the time here reflects reading five million documents, and the difference may be insignificant on small datasets.\n\nThese experiments show the trade-off clearly. Relational systems rely on joins due to normalization, but they can optimize them effectively thanks to typed scalar columns. Document databases avoid many joins, but when joins are needed, flexible semantics—like arrays—limit the available algorithms.\n\nDocumentDB for PostgreSQL sits in the middle. It relies on PostgreSQL storage and execution while preserving MongoDB semantics. As a result, `$lookup`\n\nuses only a subset of the join capabilities available in SQL to preserve this flexibility. The SQL workaround shows that performance improves when you enforce scalar semantics, but this runs counter to the expectations of a document model, where any field in one document can be an array in another.\n\nSo the real question is not which system is faster, but which trade-off you choose: flexibility with embedded arrays or optimization for scalar values.\n\nThis was tested on MongoDB 8.0 and DocumentDB 0.112 on PostgreSQL 17.10, and both can improve in the future. Optimization is possible when the field is a known scalar. But if you have a fixed schema, do you still want a document database or switch to SQL? PostgreSQL can also gain optimizations that benefit DocumentDB queries. For example, the lateral join could be memoized in a future version.\n\nIf you're thinking about using [DocumentDB for PostgreSQL](https://documentdb.io/) — whether you're migrating from MongoDB or starting fresh — don't stop at the first slow query. Look into the causes, since the trade-off between speed and flexibility can differ. Check execution plans, and [file an issue](https://github.com/documentdb/documentdb/issues) or start a discussion. More feedback from real workloads helps the contributors improve the extension. That's a major advantage of open source.", "url": "https://wpnews.pro/news/lookup-join-strategies-understanding-the-trade-offs-with-flexible-documents", "canonical_source": "https://dev.to/franckpachot/lookup-join-strategies-understanding-the-trade-offs-with-flexible-documents-ncf", "published_at": "2026-06-26 05:37:11+00:00", "updated_at": "2026-06-26 06:04:18.422331+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["MongoDB", "DocumentDB for PostgreSQL", "PostgreSQL"], "alternates": {"html": "https://wpnews.pro/news/lookup-join-strategies-understanding-the-trade-offs-with-flexible-documents", "markdown": "https://wpnews.pro/news/lookup-join-strategies-understanding-the-trade-offs-with-flexible-documents.md", "text": "https://wpnews.pro/news/lookup-join-strategies-understanding-the-trade-offs-with-flexible-documents.txt", "jsonld": "https://wpnews.pro/news/lookup-join-strategies-understanding-the-trade-offs-with-flexible-documents.jsonld"}}