diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json
index 8de0e22ae4bb3..89bed40b90722 100644
--- a/docs-mintlify/docs.json
+++ b/docs-mintlify/docs.json
@@ -82,6 +82,7 @@
"docs/data-modeling/concepts/working-with-joins",
"docs/data-modeling/concepts/code-reusability-extending-cubes",
"docs/data-modeling/concepts/polymorphic-cubes",
+ "docs/data-modeling/concepts/multi-fact-queries",
"docs/data-modeling/concepts/data-blending"
]
},
diff --git a/docs-mintlify/docs/data-modeling/concepts/multi-fact-queries.mdx b/docs-mintlify/docs/data-modeling/concepts/multi-fact-queries.mdx
new file mode 100644
index 0000000000000..2478ef352154a
--- /dev/null
+++ b/docs-mintlify/docs/data-modeling/concepts/multi-fact-queries.mdx
@@ -0,0 +1,499 @@
+---
+title: Multi-fact queries
+description: When a view includes measures from multiple root fact tables, Cube builds separate aggregating subqueries and joins their results on common dimensions.
+---
+
+When a [view](/reference/data-modeling/view) includes measures from multiple root
+fact tables, Cube can automatically execute a _multi-fact query_. Instead of
+joining all fact tables together and risking row multiplication, Cube builds a
+**separate aggregating subquery for each fact table** and then joins the results
+on the common dimensions.
+
+
+
+Multi-fact queries are powered by Tesseract, the [next-generation data modeling
+engine][link-tesseract]. Tesseract is currently in preview. Use the
+[`CUBEJS_TESSERACT_SQL_PLANNER`](/reference/configuration/environment-variables#cubejs_tesseract_sql_planner) environment variable to enable it.
+
+
+
+## When a multi-fact query is triggered
+
+A multi-fact query is triggered when a view has **multiple root fact tables**
+whose measures are queried together. Each distinct root fact table in the view
+becomes its own aggregating subquery, and the results are joined on the common
+dimensions shared across those facts.
+
+Consider a data model with two fact cubes, `orders` and `returns`. Both are
+joined to two shared dimension tables: `customers` and a `dates` date spine:
+
+
+
+```yaml title="YAML"
+cubes:
+ - name: customers
+ sql_table: customers
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+ - name: name
+ type: string
+ sql: name
+ - name: city
+ type: string
+ sql: city
+
+ - name: dates
+ sql_table: dates
+
+ dimensions:
+ - name: date
+ type: time
+ sql: date
+ primary_key: true
+
+ - name: orders
+ sql_table: orders
+
+ joins:
+ - name: customers
+ relationship: many_to_one
+ sql: "{orders}.customer_id = {customers.id}"
+ - name: dates
+ relationship: many_to_one
+ sql: "DATE_TRUNC('day', {orders}.created_at) = {dates.date}"
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+ - name: customer_id
+ type: number
+ sql: customer_id
+ - name: status
+ type: string
+ sql: status
+
+ measures:
+ - name: count
+ type: count
+ - name: total_amount
+ type: sum
+ sql: amount
+
+ - name: returns
+ sql_table: returns
+
+ joins:
+ - name: customers
+ relationship: many_to_one
+ sql: "{returns}.customer_id = {customers.id}"
+ - name: dates
+ relationship: many_to_one
+ sql: "DATE_TRUNC('day', {returns}.created_at) = {dates.date}"
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+ - name: customer_id
+ type: number
+ sql: customer_id
+
+ measures:
+ - name: count
+ type: count
+ - name: total_refund
+ type: sum
+ sql: refund_amount
+```
+
+```javascript title="JavaScript"
+cube(`customers`, {
+ sql_table: `customers`,
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ },
+
+ name: {
+ sql: `name`,
+ type: `string`
+ },
+
+ city: {
+ sql: `city`,
+ type: `string`
+ }
+ }
+})
+
+cube(`dates`, {
+ sql_table: `dates`,
+
+ dimensions: {
+ date: {
+ sql: `date`,
+ type: `time`,
+ primary_key: true
+ }
+ }
+})
+
+cube(`orders`, {
+ sql_table: `orders`,
+
+ joins: {
+ customers: {
+ relationship: `many_to_one`,
+ sql: `${orders}.customer_id = ${customers.id}`
+ },
+
+ dates: {
+ relationship: `many_to_one`,
+ sql: `DATE_TRUNC('day', ${orders}.created_at) = ${dates.date}`
+ }
+ },
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ },
+
+ customer_id: {
+ sql: `customer_id`,
+ type: `number`
+ },
+
+ status: {
+ sql: `status`,
+ type: `string`
+ }
+ },
+
+ measures: {
+ count: {
+ type: `count`
+ },
+
+ total_amount: {
+ sql: `amount`,
+ type: `sum`
+ }
+ }
+})
+
+cube(`returns`, {
+ sql_table: `returns`,
+
+ joins: {
+ customers: {
+ relationship: `many_to_one`,
+ sql: `${returns}.customer_id = ${customers.id}`
+ },
+
+ dates: {
+ relationship: `many_to_one`,
+ sql: `DATE_TRUNC('day', ${returns}.created_at) = ${dates.date}`
+ }
+ },
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ },
+
+ customer_id: {
+ sql: `customer_id`,
+ type: `number`
+ }
+ },
+
+ measures: {
+ count: {
+ type: `count`
+ },
+
+ total_refund: {
+ sql: `refund_amount`,
+ type: `sum`
+ }
+ }
+})
+```
+
+
+
+You can then define a view where `orders` and `returns` are separate root
+fact tables. The shared dimension tables — `customers` and `dates` — are
+each included with their own root-level join paths, not nested under a
+specific fact like `orders.customers`. This makes their dimensions common to
+both facts so they can be used to join the subquery results. The `prefix`
+parameter disambiguates identically named members from different fact cubes:
+
+
+
+```yaml title="YAML"
+views:
+ - name: customer_overview
+ cubes:
+ - join_path: orders
+ includes:
+ - count
+ - total_amount
+ prefix: true
+ - join_path: customers
+ includes:
+ - name
+ - city
+ - join_path: dates
+ includes:
+ - date
+ - join_path: returns
+ includes:
+ - count
+ - total_refund
+ prefix: true
+```
+
+```javascript title="JavaScript"
+view(`customer_overview`, {
+ cubes: [
+ {
+ join_path: orders,
+ includes: [`count`, `total_amount`],
+ prefix: true
+ },
+ {
+ join_path: customers,
+ includes: [`name`, `city`]
+ },
+ {
+ join_path: dates,
+ includes: [`date`]
+ },
+ {
+ join_path: returns,
+ includes: [`count`, `total_refund`],
+ prefix: true
+ }
+ ]
+})
+```
+
+
+
+This view has two root fact tables (`orders` and `returns`) and two shared
+dimension tables (`customers` and `dates`). Because each dimension table is
+included at its own root-level join path rather than scoped under a single
+fact, their dimensions are available as common join keys for both fact
+subqueries.
+
+When you query measures from both facts — such as `orders_count`,
+`orders_total_amount`, `returns_count`, and `returns_total_refund` — grouped
+by common dimensions like `name`, `city`, and `date`, Cube detects the
+multiple roots and triggers a multi-fact query.
+
+## Join path requirements
+
+To ensure correct join paths within a multi-fact view, follow these rules:
+
+- **Within each root fact table**, any join paths to related cubes (e.g.,
+ `orders.line_items`) should be listed explicitly in the view. This removes
+ ambiguity about which tables are involved in each fact's subquery.
+- **Dimension tables that join to other, less granular dimension tables**
+ (e.g., `customers` joining to `regions`) should also declare those join
+ paths explicitly in the view if those dimensions are needed.
+- **Between root fact tables and root dimension tables**, one-hop joins must
+ be defined at the cube level (as shown in the `orders` and `returns` cubes
+ above, each declaring a direct join to `customers` and `dates`). This
+ allows the multi-fact view to unambiguously resolve how each fact reaches
+ each common dimension table.
+
+In the example above, both `orders` and `returns` declare direct joins to
+`customers` and `dates`. This means the view can build separate subqueries
+where each fact independently joins to the same dimension tables — without
+relying on transitive or implicit join paths.
+
+## How multi-fact queries work
+
+Cube analyzes the join hints for each measure and groups them by their
+**join key** — the set of tables involved in the join path from the root to
+the measure's cube. Measures that share the same join key are placed in the
+same group; measures with different join keys form separate groups. When there
+are **two or more groups**, the query is classified as multi-fact.
+
+The query is then executed in the following stages:
+
+### 1. Separate aggregating subqueries
+
+For each group of measures, Cube builds an independent aggregating subquery.
+Each subquery joins only the tables needed for that group's measures, applies
+all relevant filters and segments, and aggregates the results by the common
+dimensions.
+
+For example, given a query for `orders_count`, `orders_total_amount`,
+`returns_count`, and `returns_total_refund` grouped by `name`, `city`, and
+`date`:
+
+- **Subquery 1** (orders group): joins `orders` to `customers` and `dates`,
+ computes `COUNT(*)` and `SUM(amount)`, grouped by `customers.name`,
+ `customers.city`, and `dates.date`.
+- **Subquery 2** (returns group): joins `returns` to `customers` and `dates`,
+ computes `COUNT(*)` and `SUM(refund_amount)`, grouped by `customers.name`,
+ `customers.city`, and `dates.date`.
+
+### 2. Join on common dimensions
+
+The results of the subqueries are joined with `FULL JOIN` semantics on all
+common dimension columns — in this case, `name`, `city`, and `date`. This
+ensures that all rows from both fact tables are represented, even when a
+customer has orders but no returns, or vice versa. The actual SQL
+implementation may vary depending on database capabilities.
+
+### 3. Final result
+
+The final `SELECT` pulls measures from their respective subqueries and
+dimensions from the joined result. Rows with data in only one fact table
+will show `NULL` for measures from the other.
+
+For the `customer_overview` view, the result looks like:
+
+| name | city | date | orders_count | orders_total_amount | returns_count | returns_total_refund |
+| --- | --- | --- | --- | --- | --- | --- |
+| Alice | New York | 2025-01-15 | 2 | 200.00 | 0 | NULL |
+| Alice | New York | 2025-02-10 | 2 | 225.00 | 1 | 100.00 |
+| Bob | Seattle | 2025-01-20 | 3 | 550.00 | 2 | 130.00 |
+| Charlie | New York | 2025-02-05 | 0 | NULL | 2 | 100.00 |
+| Diana | Boston | 2025-03-01 | 1 | 400.00 | 0 | NULL |
+
+Notice that Charlie has no orders and Diana has no returns — both are still
+included in the results with `NULL` values for the missing fact table.
+
+## More than two fact tables
+
+Multi-fact queries are not limited to two root fact tables. If a view includes
+three or more fact tables, each one gets its own aggregating subquery, and all
+results are joined together on the common dimensions.
+
+For instance, adding a `reviews` cube as a third root fact in the view and
+querying `orders_count`, `returns_count`, and `reviews_count` grouped by
+`name`, `city`, and `date` produces three separate subqueries, all joined on
+those common dimensions.
+
+## All facts must share the same common dimensions
+
+Every root fact table in a multi-fact view must be joinable to the **same set
+of common dimension tables**. The subquery results are joined on these common
+dimensions, so if a fact table cannot reach one of the dimension tables, the
+join will fail.
+
+If a fact table does not naturally have a foreign key for one of the common
+dimension tables, you can create a **synthetic join** by selecting `NULL` for
+the missing foreign key in the cube's `sql` definition:
+
+
+
+```yaml title="YAML"
+cubes:
+ - name: refunds
+ sql: >
+ SELECT *, NULL AS customer_id FROM refunds
+ joins:
+ - name: customers
+ relationship: many_to_one
+ sql: "{refunds}.customer_id = {customers.id}"
+ - name: dates
+ relationship: many_to_one
+ sql: "DATE_TRUNC('day', {refunds}.created_at) = {dates.date}"
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+
+ measures:
+ - name: count
+ type: count
+ - name: total_amount
+ type: sum
+ sql: amount
+```
+
+```javascript title="JavaScript"
+cube(`refunds`, {
+ sql: `SELECT *, NULL AS customer_id FROM refunds`,
+
+ joins: {
+ customers: {
+ relationship: `many_to_one`,
+ sql: `${refunds}.customer_id = ${customers.id}`
+ },
+
+ dates: {
+ relationship: `many_to_one`,
+ sql: `DATE_TRUNC('day', ${refunds}.created_at) = ${dates.date}`
+ }
+ },
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ }
+ },
+
+ measures: {
+ count: {
+ type: `count`
+ },
+
+ total_amount: {
+ sql: `amount`,
+ type: `sum`
+ }
+ }
+})
+```
+
+
+
+In this example, the `refunds` table has no `customer_id` column. By selecting
+`NULL AS customer_id` in the cube's SQL, the join to `customers` is
+syntactically valid. The `customer_id` will always be `NULL`, so refund rows
+will never match a specific customer, but the subquery can still participate
+in the multi-fact join on the full set of common dimensions.
+
+## Filters in multi-fact queries
+
+Filters on **common dimensions** (like `name`, `city`, or `date`) are applied to every
+subquery, ensuring consistent filtering across all fact tables.
+
+Filters on **fact-specific dimensions** (like `orders.status`) are applied only
+to the subquery for that specific fact table. Other fact table subqueries remain
+unaffected.
+
+**Measure filters** (e.g., `orders_count > 1`) are applied as `HAVING`
+conditions after the subqueries are joined, filtering the combined result set.
+
+## Segments in multi-fact queries
+
+[Segments](/reference/data-modeling/segments) that belong to a specific fact table are applied only
+to that fact table's subquery. For example, applying an `orders.completed_orders`
+segment filters only the orders subquery while leaving returns unaffected.
+
+[link-tesseract]: https://cube.dev/blog/introducing-tesseract
diff --git a/docs/content/product/data-modeling/concepts/_meta.js b/docs/content/product/data-modeling/concepts/_meta.js
index a43d3755612f2..be4c3fd7fb7ab 100644
--- a/docs/content/product/data-modeling/concepts/_meta.js
+++ b/docs/content/product/data-modeling/concepts/_meta.js
@@ -8,5 +8,6 @@ export default {
},
"code-reusability-extending-cubes": "Extension",
"polymorphic-cubes": "Polymorphic cubes",
+ "multi-fact-queries": "Multi-fact queries",
"data-blending": "Data blending"
}
diff --git a/docs/content/product/data-modeling/concepts/multi-fact-queries.mdx b/docs/content/product/data-modeling/concepts/multi-fact-queries.mdx
new file mode 100644
index 0000000000000..5c4df921fd423
--- /dev/null
+++ b/docs/content/product/data-modeling/concepts/multi-fact-queries.mdx
@@ -0,0 +1,498 @@
+# Multi-fact queries
+
+When a [view][ref-views] includes measures from multiple root fact tables, Cube
+can automatically execute a _multi-fact query_. Instead of joining all fact
+tables together and risking row multiplication, Cube builds a **separate
+aggregating subquery for each fact table** and then joins the results on the
+common dimensions.
+
+
+
+Multi-fact queries are powered by Tesseract, the [next-generation data modeling
+engine][link-tesseract]. Tesseract is currently in preview. Use the
+CUBEJS_TESSERACT_SQL_PLANNER environment variable to enable it.
+
+
+
+## When a multi-fact query is triggered
+
+A multi-fact query is triggered when a view has **multiple root fact tables**
+whose measures are queried together. Each distinct root fact table in the view
+becomes its own aggregating subquery, and the results are joined on the common
+dimensions shared across those facts.
+
+Consider a data model with two fact cubes, `orders` and `returns`. Both are
+joined to two shared dimension tables: `customers` and a `dates` date spine:
+
+
+
+```yaml
+cubes:
+ - name: customers
+ sql_table: customers
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+ - name: name
+ type: string
+ sql: name
+ - name: city
+ type: string
+ sql: city
+
+ - name: dates
+ sql_table: dates
+
+ dimensions:
+ - name: date
+ type: time
+ sql: date
+ primary_key: true
+
+ - name: orders
+ sql_table: orders
+
+ joins:
+ - name: customers
+ relationship: many_to_one
+ sql: "{orders}.customer_id = {customers.id}"
+ - name: dates
+ relationship: many_to_one
+ sql: "DATE_TRUNC('day', {orders}.created_at) = {dates.date}"
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+ - name: customer_id
+ type: number
+ sql: customer_id
+ - name: status
+ type: string
+ sql: status
+
+ measures:
+ - name: count
+ type: count
+ - name: total_amount
+ type: sum
+ sql: amount
+
+ - name: returns
+ sql_table: returns
+
+ joins:
+ - name: customers
+ relationship: many_to_one
+ sql: "{returns}.customer_id = {customers.id}"
+ - name: dates
+ relationship: many_to_one
+ sql: "DATE_TRUNC('day', {returns}.created_at) = {dates.date}"
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+ - name: customer_id
+ type: number
+ sql: customer_id
+
+ measures:
+ - name: count
+ type: count
+ - name: total_refund
+ type: sum
+ sql: refund_amount
+```
+
+```javascript
+cube(`customers`, {
+ sql_table: `customers`,
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ },
+
+ name: {
+ sql: `name`,
+ type: `string`
+ },
+
+ city: {
+ sql: `city`,
+ type: `string`
+ }
+ }
+})
+
+cube(`dates`, {
+ sql_table: `dates`,
+
+ dimensions: {
+ date: {
+ sql: `date`,
+ type: `time`,
+ primary_key: true
+ }
+ }
+})
+
+cube(`orders`, {
+ sql_table: `orders`,
+
+ joins: {
+ customers: {
+ relationship: `many_to_one`,
+ sql: `${orders}.customer_id = ${customers.id}`
+ },
+
+ dates: {
+ relationship: `many_to_one`,
+ sql: `DATE_TRUNC('day', ${orders}.created_at) = ${dates.date}`
+ }
+ },
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ },
+
+ customer_id: {
+ sql: `customer_id`,
+ type: `number`
+ },
+
+ status: {
+ sql: `status`,
+ type: `string`
+ }
+ },
+
+ measures: {
+ count: {
+ type: `count`
+ },
+
+ total_amount: {
+ sql: `amount`,
+ type: `sum`
+ }
+ }
+})
+
+cube(`returns`, {
+ sql_table: `returns`,
+
+ joins: {
+ customers: {
+ relationship: `many_to_one`,
+ sql: `${returns}.customer_id = ${customers.id}`
+ },
+
+ dates: {
+ relationship: `many_to_one`,
+ sql: `DATE_TRUNC('day', ${returns}.created_at) = ${dates.date}`
+ }
+ },
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ },
+
+ customer_id: {
+ sql: `customer_id`,
+ type: `number`
+ }
+ },
+
+ measures: {
+ count: {
+ type: `count`
+ },
+
+ total_refund: {
+ sql: `refund_amount`,
+ type: `sum`
+ }
+ }
+})
+```
+
+
+
+You can then define a view where `orders` and `returns` are separate root
+fact tables. The shared dimension tables — `customers` and `dates` — are
+each included with their own root-level join paths, not nested under a
+specific fact like `orders.customers`. This makes their dimensions common to
+both facts so they can be used to join the subquery results. The `prefix`
+parameter disambiguates identically named members from different fact cubes:
+
+
+
+```yaml
+views:
+ - name: customer_overview
+ cubes:
+ - join_path: orders
+ includes:
+ - count
+ - total_amount
+ prefix: true
+ - join_path: customers
+ includes:
+ - name
+ - city
+ - join_path: dates
+ includes:
+ - date
+ - join_path: returns
+ includes:
+ - count
+ - total_refund
+ prefix: true
+```
+
+```javascript
+view(`customer_overview`, {
+ cubes: [
+ {
+ join_path: orders,
+ includes: [`count`, `total_amount`],
+ prefix: true
+ },
+ {
+ join_path: customers,
+ includes: [`name`, `city`]
+ },
+ {
+ join_path: dates,
+ includes: [`date`]
+ },
+ {
+ join_path: returns,
+ includes: [`count`, `total_refund`],
+ prefix: true
+ }
+ ]
+})
+```
+
+
+
+This view has two root fact tables (`orders` and `returns`) and two shared
+dimension tables (`customers` and `dates`). Because each dimension table is
+included at its own root-level join path rather than scoped under a single
+fact, their dimensions are available as common join keys for both fact
+subqueries.
+
+When you query measures from both facts — such as `orders_count`,
+`orders_total_amount`, `returns_count`, and `returns_total_refund` — grouped
+by common dimensions like `name`, `city`, and `date`, Cube detects the
+multiple roots and triggers a multi-fact query.
+
+## Join path requirements
+
+To ensure correct join paths within a multi-fact view, follow these rules:
+
+- **Within each root fact table**, any join paths to related cubes (e.g.,
+ `orders.line_items`) should be listed explicitly in the view. This removes
+ ambiguity about which tables are involved in each fact's subquery.
+- **Dimension tables that join to other, less granular dimension tables**
+ (e.g., `customers` joining to `regions`) should also declare those join
+ paths explicitly in the view if those dimensions are needed.
+- **Between root fact tables and root dimension tables**, one-hop joins must
+ be defined at the cube level (as shown in the `orders` and `returns` cubes
+ above, each declaring a direct join to `customers` and `dates`). This
+ allows the multi-fact view to unambiguously resolve how each fact reaches
+ each common dimension table.
+
+In the example above, both `orders` and `returns` declare direct joins to
+`customers` and `dates`. This means the view can build separate subqueries
+where each fact independently joins to the same dimension tables — without
+relying on transitive or implicit join paths.
+
+## How multi-fact queries work
+
+Cube analyzes the join hints for each measure and groups them by their
+**join key** — the set of tables involved in the join path from the root to
+the measure's cube. Measures that share the same join key are placed in the
+same group; measures with different join keys form separate groups. When there
+are **two or more groups**, the query is classified as multi-fact.
+
+The query is then executed in the following stages:
+
+### 1. Separate aggregating subqueries
+
+For each group of measures, Cube builds an independent aggregating subquery.
+Each subquery joins only the tables needed for that group's measures, applies
+all relevant filters and segments, and aggregates the results by the common
+dimensions.
+
+For example, given a query for `orders_count`, `orders_total_amount`,
+`returns_count`, and `returns_total_refund` grouped by `name`, `city`, and
+`date`:
+
+- **Subquery 1** (orders group): joins `orders` to `customers` and `dates`,
+ computes `COUNT(*)` and `SUM(amount)`, grouped by `customers.name`,
+ `customers.city`, and `dates.date`.
+- **Subquery 2** (returns group): joins `returns` to `customers` and `dates`,
+ computes `COUNT(*)` and `SUM(refund_amount)`, grouped by `customers.name`,
+ `customers.city`, and `dates.date`.
+
+### 2. Join on common dimensions
+
+The results of the subqueries are joined with `FULL JOIN` semantics on all
+common dimension columns — in this case, `name`, `city`, and `date`. This
+ensures that all rows from both fact tables are represented, even when a
+customer has orders but no returns, or vice versa. The actual SQL
+implementation may vary depending on database capabilities.
+
+### 3. Final result
+
+The final `SELECT` pulls measures from their respective subqueries and
+dimensions from the joined result. Rows with data in only one fact table
+will show `NULL` for measures from the other.
+
+For the `customer_overview` view, the result looks like:
+
+| name | city | date | orders_count | orders_total_amount | returns_count | returns_total_refund |
+| --- | --- | --- | --- | --- | --- | --- |
+| Alice | New York | 2025-01-15 | 2 | 200.00 | 0 | NULL |
+| Alice | New York | 2025-02-10 | 2 | 225.00 | 1 | 100.00 |
+| Bob | Seattle | 2025-01-20 | 3 | 550.00 | 2 | 130.00 |
+| Charlie | New York | 2025-02-05 | 0 | NULL | 2 | 100.00 |
+| Diana | Boston | 2025-03-01 | 1 | 400.00 | 0 | NULL |
+
+Notice that Charlie has no orders and Diana has no returns — both are still
+included in the results with `NULL` values for the missing fact table.
+
+## More than two fact tables
+
+Multi-fact queries are not limited to two root fact tables. If a view includes
+three or more fact tables, each one gets its own aggregating subquery, and all
+results are joined together on the common dimensions.
+
+For instance, adding a `reviews` cube as a third root fact in the view and
+querying `orders_count`, `returns_count`, and `reviews_count` grouped by
+`name`, `city`, and `date` produces three separate subqueries, all joined on
+those common dimensions.
+
+## All facts must share the same common dimensions
+
+Every root fact table in a multi-fact view must be joinable to the **same set
+of common dimension tables**. The subquery results are joined on these common
+dimensions, so if a fact table cannot reach one of the dimension tables, the
+join will fail.
+
+If a fact table does not naturally have a foreign key for one of the common
+dimension tables, you can create a **synthetic join** by selecting `NULL` for
+the missing foreign key in the cube's `sql` definition:
+
+
+
+```yaml
+cubes:
+ - name: refunds
+ sql: >
+ SELECT *, NULL AS customer_id FROM refunds
+ joins:
+ - name: customers
+ relationship: many_to_one
+ sql: "{refunds}.customer_id = {customers.id}"
+ - name: dates
+ relationship: many_to_one
+ sql: "DATE_TRUNC('day', {refunds}.created_at) = {dates.date}"
+
+ dimensions:
+ - name: id
+ type: number
+ sql: id
+ primary_key: true
+
+ measures:
+ - name: count
+ type: count
+ - name: total_amount
+ type: sum
+ sql: amount
+```
+
+```javascript
+cube(`refunds`, {
+ sql: `SELECT *, NULL AS customer_id FROM refunds`,
+
+ joins: {
+ customers: {
+ relationship: `many_to_one`,
+ sql: `${refunds}.customer_id = ${customers.id}`
+ },
+
+ dates: {
+ relationship: `many_to_one`,
+ sql: `DATE_TRUNC('day', ${refunds}.created_at) = ${dates.date}`
+ }
+ },
+
+ dimensions: {
+ id: {
+ sql: `id`,
+ type: `number`,
+ primary_key: true
+ }
+ },
+
+ measures: {
+ count: {
+ type: `count`
+ },
+
+ total_amount: {
+ sql: `amount`,
+ type: `sum`
+ }
+ }
+})
+```
+
+
+
+In this example, the `refunds` table has no `customer_id` column. By selecting
+`NULL AS customer_id` in the cube's SQL, the join to `customers` is
+syntactically valid. The `customer_id` will always be `NULL`, so refund rows
+will never match a specific customer, but the subquery can still participate
+in the multi-fact join on the full set of common dimensions.
+
+## Filters in multi-fact queries
+
+Filters on **common dimensions** (like `name`, `city`, or `date`) are applied to every
+subquery, ensuring consistent filtering across all fact tables.
+
+Filters on **fact-specific dimensions** (like `orders.status`) are applied only
+to the subquery for that specific fact table. Other fact table subqueries remain
+unaffected.
+
+**Measure filters** (e.g., `orders_count > 1`) are applied as `HAVING`
+conditions after the subqueries are joined, filtering the combined result set.
+
+## Segments in multi-fact queries
+
+[Segments][ref-segments] that belong to a specific fact table are applied only
+to that fact table's subquery. For example, applying an `orders.completed_orders`
+segment filters only the orders subquery while leaving returns unaffected.
+
+[ref-views]: /product/data-modeling/reference/view
+[ref-segments]: /product/data-modeling/reference/segments
+[link-tesseract]: https://cube.dev/blog/introducing-tesseract