Relational Queries (Joins) in Document Databases

Last updated: July 2026

A relational query in a document database is a join done with references and lookups instead of foreign keys — or avoided by embedding. That “or” is the whole subject: document databases give you three ways to relate data, and the design decision is when the join happens — at write time (embed), at query time in the server (lookup), or at query time in the application (references plus a follow-up fetch).

Key takeaways

QuestionAnswer
Can document DBs join?Yes — lookups do left outer joins, returning arrays, not rows
The real decisionEmbed vs. reference — when does the join happen?
Default ruleEmbed what’s read together and bounded; reference what stands alone or grows
The performance truthServer-side lookups are nested-loop joins — fine per request, wrong for analytics
The classic bugN+1 queries — fixed by batching, includes, or embedding

The three ways to relate documents

// 1 · Embed — the "join" happened at write time
{ _id: 1, title: "Dune", author: { name: "Frank Herbert", born: 1920 } }

// 2 · Reference + $lookup — the join happens at query time, server-side
db.books.aggregate([
  { $lookup: { from: "authors", localField: "authorId",
               foreignField: "_id", as: "author" } },  // left outer join → array
  { $unwind: "$author" }                               // flatten → inner join
])

// 3 · Reference + application-side join — the ODM/SDK way (below)

The third path is where most application code lives — typed references walked eagerly in one request, so related documents arrive together without a pipeline:

// JavaScript / Node.js — Back4app JS SDK
// A join in a document database: Pointer + include, one request
const query = new Parse.Query('Comment');
query.equalTo('post', postPointer);   // Comment.post is a Pointer<Post>
query.include('author');              // "join" the author document in
const comments = await query.find();

const name = comments[0].get('author').get('username'); // already loaded — no N+1

Embed vs. reference: the decision

Embed or reference decision flowData always read with its parent and bounded in size should be embedded; data that is shared, independently updated, or unbounded should be referenced, with the reference direction flipped for enormous child sets.

no

yes

no

yes

yes

no

yes

Read together with
the parent, always?

Reference

Bounded size?
(no endless growth)

Shared with
other parents?

Embed

Enormous child count?

Flip it: store the
parent ref in each child

Data always read with its parent and bounded in size should be embedded; data that is shared, independently updated, or unbounded should be referenced, with the reference direction flipped for enormous child sets.
DimensionEmbedReference
Read patternAlways fetched with parentFetched standalone or on demand
Write patternUpdated with parent, atomicallyUpdated independently
CardinalityOne-to-fewOne-to-many and beyond
GrowthBounded (a person’s addresses)Unbounded (a post’s comments)
SharingBelongs to one parentShared across parents
The join costZero — paid at write timePaid per query — lookup, include, or batch

The cardinality tiers give the same table as rules of thumb: one-to-few embeds the array, one-to-many references by ID, one-to-enormous flips direction — the child stores the parent reference, because no parent document can hold an ever-growing array. Which points at the two anti-patterns behind most document-modeling incidents: unbounded arrays and the 16MB document cap they eventually threaten. The standard fixes — reference instead, embed a bounded subset (newest N with an overflow collection), or bucket children into grouped documents — are all versions of “stop the document growing.”

The lookup, honestly

$lookup is a real join with two honest asterisks. Shape: it returns matches as an embedded array per input document — $unwind flattens it to rows and, without preserve-empty semantics, converts left-join to inner-join behavior. Performance: document engines execute nested-loop joins only, and public benchmarks are blunt — joining a million documents took tens of seconds with indexes, against half a second for the embedded equivalent. The operational rules that follow: always index the foreign field (without it, every input document triggers a collection scan), use lookups for per-request joins over a handful of documents, and never build analytics fan-outs on them — that workload belongs to a warehouse or a relational engine.

The N+1 problem, and its four exits

The classic bug: one query for N parents, then a loop issuing one query per parent for its children — N+1 round trips that scale with the page size. The exits, best first: batch — collect the parent IDs and fetch all children in one contained-in query (good ODMs’ populate does this for you); include — SDK-level eager loading that returns referenced documents in the same request, as in the tabs above; lookup — one server-side pipeline; embed — the join stops existing. What turns N+1 from bug to architecture is not noticing it: it ships fast on ten test records and melts under a thousand — the full pathology has its own entry in this glossary’s registry.

Common use cases

  • Content with authorship. Posts, comments, authors — references with includes for the list views, embeds for the display-only snapshot fields.
  • Catalogs and orders. The canonical extended reference: an order embeds the product name and price as-sold (immutable snapshot) plus a reference to the live product.
  • Activity and event streams. One-to-enormous — child documents holding parent references, never arrays on the parent.
  • User profiles. The embed showcase: addresses, preferences, settings — read together, bounded, owned.
  • Social graphs. Many-to-many reference arrays — and the honest signal that, past a point, this shape wants a relational or graph engine.

Embed, reference, or switch engines? A decision matrix

Embed when…Reference when…Use a relational DB when…
Read and updated togetherAccessed standaloneJoins are the workload, not the exception
Small and boundedUnbounded or high-cardinalityAd-hoc analytics across entities
Owned by one parentShared across parentsStrict referential integrity required
Atomic updates with parent matterIndependent update cyclesComplex multi-row transactions
The classic case: profile fieldsThe classic case: commentsThe classic case: many-to-many-heavy domains

The third column is the section vendor pages won’t write: if every screen needs three lookups and referential integrity keeps you up at night, the data is asking for tables — document models win when access patterns are known and hierarchical, not as a universal replacement.

Limitations and trade-offs

  • Denormalization is a debt instrument. Duplicated fields kill joins but must be repaid on update — fan-out writes, staleness windows, and consistency code (transactions or change-stream triggers) are the interest.
  • No foreign keys, no safety net. References don’t enforce existence; deleting an author orphans book references silently unless your platform or code cleans up.
  • Lookups don’t optimize. No join reordering, no hash strategies — pipeline order is your query plan.
  • Migrations between shapes are real projects. Embedded-to-referenced (the growing-array rescue) means backfilling collections and rewriting queries — model for tomorrow’s cardinality, not today’s.
  • The 16MB cap is a cliff, not a warning. Growth patterns that approach it degrade performance long before they hit it.

Relational queries on Back4app

Back4app is an open-source Backend-as-a-Service (BaaS) platform that combines a managed database, auto-generated REST and GraphQL APIs, authentication, file storage, and Cloud Code serverless functions. Its document database speaks a relational vocabulary by design: Pointers declare one-to-many edges, Relations handle many-to-many, and include() walks the edges in a single request — the N+1 exit built into the SDK, as the code tabs above show. The auto-generated GraphQL API nests related objects in one query for free, and deletes can cascade through Cloud Code triggers — the referential-integrity safety net document engines leave out.

Frequently asked questions

Does MongoDB support joins?

Not SQL-style joins — but yes, relationally useful ones. The $lookup aggregation stage performs a left outer join, attaching matching documents from another collection as an embedded array rather than flat rows. Adding $unwind converts it to inner-join semantics. What differs from SQL is the shape of the result, the performance profile, and the fact that joins are the exception rather than the default.

Should I embed or reference related data?

The consensus rule: embed what you read, update, and archive together — small, bounded, tightly coupled data. Reference what stands alone: shared across parents, updated independently, high-cardinality, or unbounded. The vendor guidance says to favor embedding absent a compelling reason, and the compelling reasons are precisely those four.

How does cardinality change the modeling decision?

The classic tiers: one-to-few (a person's addresses) — embed the array. One-to-many (hundreds to thousands) — an array of references. One-to-enormous (a machine's log events) — flip the direction and store the parent reference in each child, because the parent cannot hold an ever-growing array. Many-to-many — arrays of references, sometimes on both sides.

Is $lookup slow?

Relative to relational joins, consistently yes: document engines run nested-loop joins without the merge and hash strategies relational optimizers have. Public benchmarks joining a million documents measured tens of seconds even indexed, versus half a second for the embedded equivalent. The operational rules: always index the foreign field, keep $lookup on per-request paths joining a handful of documents, and never build analytics fan-outs on it.

What is the 16MB document limit and why does it matter here?

A hard cap on a single document's size — and the reason "just embed everything" fails. Unbounded embedded arrays (comments, logs, events) grow toward the cap and degrade cache and index efficiency long before hitting it. The standard fixes: switch to references, embed only a bounded subset (the newest N) with an overflow collection, or bucket children into grouped documents.

What is the N+1 problem in document databases?

Fetching N parents with one query, then issuing one more query per parent for its related data — N+1 round trips that grow with the result set. The fixes, in order of preference: batch the second step into a single query over collected IDs, use a server-side lookup in one pipeline, fetch related documents in one request via the SDK's include mechanism, or embed so the "join" happened at write time.

How do ODMs and backend SDKs express relations?

As typed references with an eager-loading operator. Document ODMs declare reference fields and populate them with batched follow-up queries; backend SDKs use Pointers — a typed reference to another object — and an include operator that fetches the referenced documents in the same request, plus Relation types for large many-to-many sets. Same idea everywhere: declare the edge, then choose when to walk it.

When is a relational database simply the better choice?

When joins are the workload rather than the exception: many-to-many-heavy domains, ad-hoc analytics across entities, strict referential integrity, and complex multi-row transactions. Document models win when access patterns are known and hierarchical — a document per screen of data. If every query needs three lookups, the data is telling you it wants tables.

Related terms

Compare with

Further reading

Ready to build your backend?

Start your project on Back4app in minutes — database, auth, APIs, and Cloud Code included. No credit card required.

Written and reviewed by Back4app Engineering, Back4app Engineering · Published 2026-07-25