---
term: 'Relational Queries (Joins) in Document Databases'
seoTitle: 'Joins in Document Databases: Embedding, References & Lookups'
headline: 'Relational Queries (Joins) in Document Databases'
slug: relational-queries-document-databases
category: database
shortDefinition: 'A relational query in a document database is a join done with references and lookups instead of foreign keys — or avoided by embedding.'
relatedTerms:
  - n-plus-one-query-problem
  - database-abstraction-layer
  - auto-generated-database-apis
  - overfetching-underfetching
  - data-modeling
contrastsWith:
  - n-plus-one-query-problem
faq:
  - question: 'Does MongoDB support joins?'
    answer: '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.'
  - question: 'Should I embed or reference related data?'
    answer: '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.'
  - question: 'How does cardinality change the modeling decision?'
    answer: '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.'
  - question: 'Is $lookup slow?'
    answer: '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.'
  - question: 'What is the 16MB document limit and why does it matter here?'
    answer: '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.'
  - question: 'What is the N+1 problem in document databases?'
    answer: '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.'
  - question: 'How do ODMs and backend SDKs express relations?'
    answer: '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.'
  - question: 'When is a relational database simply the better choice?'
    answer: '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.'
codeLanguages: [javascript, dart, swift, kotlin]
externalAuthorities:
  - name: 'MongoDB $lookup aggregation reference'
    url: 'https://www.mongodb.com/docs/manual/reference/operator/aggregation/lookup/'
  - name: 'MongoDB — Embedding vs. References'
    url: 'https://www.mongodb.com/docs/manual/data-modeling/concepts/embedding-vs-references/'
  - name: 'Mongoose populate documentation'
    url: 'https://mongoosejs.com/docs/populate.html'
  - name: 'SDK relational data guide'
    url: 'https://docs.parseplatform.org/js/guide/#relational-data'
cta:
  title: 'Joins without the modeling headache'
  text: 'Back4app gives document data a relational vocabulary: Pointers and Relations declare the edges, include() walks them in one request, and the auto-generated GraphQL API nests related objects in a single query. No N+1, no aggregation pipelines to hand-tune.'
  linkText: 'Start building free'
  linkUrl: 'https://www.back4app.com/signup'
author: 'Back4app Engineering'
publishedDate: '2026-07-25'
translationKey: relational-queries-document-databases
---

**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

| Question | Answer |
| --- | --- |
| Can document DBs join? | Yes — lookups do left outer joins, returning arrays, not rows |
| The real decision | Embed vs. reference — when does the join happen? |
| Default rule | Embed what's read together and bounded; reference what stands alone or grows |
| The performance truth | Server-side lookups are nested-loop joins — fine per request, wrong for analytics |
| The classic bug | N+1 queries — fixed by batching, includes, or embedding |

## The three ways to relate documents

```javascript
// 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:**

```javascript
// 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
```

**Flutter:**

```dart
// Flutter / Dart — Back4app Flutter SDK
// A join in a document database: Pointer + include, one request
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
  ..whereEqualTo('post', postPointer)
  ..includeObject(['author']); // "join" the author document in
final response = await query.query();

final author = (response.results!.first as ParseObject)
    .get<ParseObject>('author'); // already loaded — no N+1
```

**Swift:**

```swift
// iOS / Swift — Back4app Swift SDK
// A join in a document database: Pointer + include, one request
let query = Comment.query("post" == postPointer)
  .include("author") // "join" the author document in
query.find { result in
  if case .success(let comments) = result {
    print(comments.first?.author?.username ?? "") // already loaded — no N+1
  }
}
```

**Kotlin:**

```kotlin
// Android / Kotlin — Back4app Android SDK
// A join in a document database: Pointer + include, one request
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", postPointer)
query.include("author") // "join" the author document in
query.findInBackground { comments, e ->
  if (e == null) {
    val name = comments[0].getParseObject("author")?.getString("username")
    Log.d("Comments", "by $name") // already loaded — no N+1
  }
}
```

## Embed vs. reference: the decision

```mermaid
flowchart TB
  accTitle: Embed or reference decision flow
  accDescr: 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.
  A{"Read together with<br/>the parent, always?"} -- no --> R["Reference"]
  A -- yes --> B{"Bounded size?<br/>(no endless growth)"}
  B -- no --> R
  B -- yes --> C{"Shared with<br/>other parents?"}
  C -- yes --> R
  C -- no --> E["Embed"]
  R --> D{"Enormous child count?"}
  D -- yes --> F["Flip it: store the<br/>parent ref in each child"]
```

| Dimension | Embed | Reference |
| --- | --- | --- |
| Read pattern | Always fetched with parent | Fetched standalone or on demand |
| Write pattern | Updated with parent, atomically | Updated independently |
| Cardinality | One-to-few | One-to-many and beyond |
| Growth | Bounded (a person's addresses) | Unbounded (a post's comments) |
| Sharing | Belongs to one parent | Shared across parents |
| The join cost | Zero — paid at write time | Paid 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`](https://www.mongodb.com/docs/manual/reference/operator/aggregation/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 together | Accessed standalone | Joins are the workload, not the exception |
| Small and bounded | Unbounded or high-cardinality | Ad-hoc analytics across entities |
| Owned by one parent | Shared across parents | Strict referential integrity required |
| Atomic updates with parent matter | Independent update cycles | Complex multi-row transactions |
| The classic case: profile fields | The classic case: comments | The 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](https://docs.parseplatform.org/js/guide/#relational-data), 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.
