Why Your Fabric Totals Double When You Join Orders to Order Lines

· Gustav Kruse, Kruse Consulting AB

Your revenue did not grow overnight.

You join orders to order lines in a Fabric lakehouse or warehouse, then sum an order-level column. Every order total is now counted once per line, not once per order. Two lines, double. Three lines, triple. The query raises no error, so the number reaches a report first.

Quick answer: a join from orders to order lines is one-to-many. It repeats each order row for every matching line, so summing order_total afterwards counts each order several times. Compare COUNT(*) with COUNT(DISTINCT order_id) across the join to confirm it. Then aggregate the lines to one row per order before you join.

The symptom: your order total just doubled

Take two small tables in a Fabric lakehouse. The data is synthetic and shown in full below. Every number in this article comes from running these queries on that data in SQLite on 7 November 2026, not in a live Fabric workspace. The queries are plain T-SQL apart from the string concatenation noted below.

orders has 4 rows, and its order_total column sums to 530:

order_idcustomer_idorder_total
10011120.00
1002280.00
10031250.00
1004380.00

order_lines has 8 rows: 2 lines for order 1001, 2 for 1002, 3 for 1003 and 1 for 1004. Its line_amount column also sums to 530:

order_idline_noline_amount
1001170.00
1001250.00
1002150.00
1002230.00
10031100.00
10032100.00
1003350.00
1004180.00

Now the obvious query:

SELECT SUM(o.order_total) AS revenue
FROM orders AS o
JOIN order_lines AS l ON l.order_id = o.order_id;

It returns 1230. That is 2.3 times the real figure, and nothing in the query looks wrong. The only visible clue is the row count, covered below.

Why it happens: join grain vs table grain

Grain is what one row in a table represents. In orders, one row is one order. In order_lines, one row is one line. Microsoft's modelling guidance says fact tables should always load data at a consistent grain (Microsoft Learn, star schema guidance).

Join fan-out is what happens when you join a table to one at a finer grain. The result takes the finer grain. Each order row is copied once per matching line, and order_total goes with every copy:

The sum is 1230. The inflation isn't random: each order is weighted by its own line count.

How to find the grain of a fact table in Fabric

The grain is the smallest set of columns that is unique per row. For a line table it is usually the header reference plus a line number. Krusgraph, an MCP tool that reads Fabric table metadata, keys a line table on exactly that pair when it infers relationships.

Confirm it with a count. If COUNT(*) equals COUNT(DISTINCT CONCAT(order_id, '-', line_no)), the pair is the grain. On the example both are 8. (In SQLite the concatenation is written order_id || '-' || line_no.)

Cross join: the same failure, at full size

A cross join pairs every row of one table with every row of another. In practice it is rarely written on purpose. It appears when a join condition is missing, or when a key is duplicated in a table you assumed was unique. Two customers rows for one customer_id double every order for that customer.

Diagnose it before you trust a total

Run this fan-out check before you trust any total built on a join. It compares rows with distinct order keys, before and after the join:

SELECT
  COUNT(*)                   AS joined_rows,
  COUNT(DISTINCT o.order_id) AS distinct_orders
FROM orders AS o
JOIN order_lines AS l ON l.order_id = o.order_id;

On the example data:

MeasureBefore join (orders)After join
Rows48
Distinct order_id44
SUM(order_total)5301230

If joined_rows is larger than distinct_orders, the join has fanned out. Any order-level sum on top of it is wrong. Equal counts mean one row per order.

To see where the duplication comes from, group by the key:

SELECT o.order_id, COUNT(*) AS rows_per_order
FROM orders AS o
JOIN order_lines AS l ON l.order_id = o.order_id
GROUP BY o.order_id
HAVING COUNT(*) > 1;

It returns three orders, with 2, 2 and 3 rows. Run the same query on a table you believe has a unique key, such as customers. Any row it returns is a duplicate key.

The pattern is not limited to hand-built tables. In SAP data replicated to Fabric, joining VBAK (order header) to VBAP (order items) repeats the header value VBAK.NETWR on every item row. Summing it multiplies the order value once per line.

Fix it: pre-aggregate before joining

Pre-aggregation means reducing the finer-grained table to the other table's grain before the join. Aggregate the lines to one row per order, then join:

WITH line_totals AS (
  SELECT order_id,
         SUM(line_amount) AS lines_total,
         COUNT(*)         AS line_count
  FROM order_lines
  GROUP BY order_id
)
SELECT COUNT(*)            AS joined_rows,
       SUM(o.order_total)  AS revenue,
       SUM(lt.lines_total) AS revenue_from_lines
FROM orders AS o
JOIN line_totals AS lt ON lt.order_id = o.order_id;

Result on the example data: 4 rows, revenue 530, revenue from lines 530. Rows went from 8 back to 4, and the total is back to the true figure. Rerun the fan-out check here and both counts match.

This is the fix to reach for first. It is plain T-SQL, so it runs in a lakehouse SQL analytics endpoint, a warehouse or a view. Line-level measures such as line_count stay available in the same result.

Fix it: COUNT DISTINCT and window-function alternatives

FixWhat it doesResult on exampleUse it when
Pre-aggregationGroups lines to order grain before the join530Default choice
Window function (ROW_NUMBER)Keeps one joined row per order, then sums530You need line columns in the same query
COUNT(DISTINCT order_id)Counts orders once, however many rows4 ordersYou need a count, not a sum
SUM(DISTINCT order_total)Sums each distinct value once450Never for totals

A window function computes a value across related rows without collapsing them. Number the joined rows per order and keep the first one. That restores order grain:

SELECT SUM(order_total) AS revenue
FROM (
  SELECT o.order_total,
         ROW_NUMBER() OVER (PARTITION BY o.order_id ORDER BY l.line_no) AS rn
  FROM orders AS o
  JOIN order_lines AS l ON l.order_id = o.order_id
) AS x
WHERE rn = 1;

COUNT DISTINCT counts each distinct value once. It fixes counts; it doesn't fix sums.

SUM(DISTINCT order_total) looks like the sum version of the same idea. It removes duplicate values, not duplicate orders. Two orders in the example both total 80.00, so one disappears. The result is 450 instead of 530.

That error is quieter than the original, since 450 looks plausible.

Fix the semantic model too

A corrected query does not protect a Power BI semantic model built on the same tables. Say the model loads a flat table built from the naive join, so order_total sits on every line row. A measure that sums it there inflates exactly as the query did.

Microsoft generally does not recommend relating two fact tables with many-to-many cardinality. It recommends a star schema with one-to-many relationships instead (Microsoft Learn, many-to-many relationship guidance). For this case:

Catch fan-out before it reaches a report

The fan-out check works when you know which joins need it. In an unfamiliar lakehouse, you often don't.

The free check on krusgraph.com reads CSV or Excel files, or a pasted list of columns. It reports keys, relationships and orphan rows, each with the rule behind it.

One built-in example is "A total that doubles". The check needs no account. Files are read in your browser, not uploaded.

With the Krusgraph MCP connector, validate_plan counts duplicate keys, NULL keys and references with no matching row. create_backend doesn't run it automatically, so you ask for it. The steps are in the guide to review a check before you create anything from it.

What a passing check shows, and what it does not:

FAQ

Why does my revenue double when I join two Fabric tables?

One table is at a finer grain than the other. Joining orders to order lines repeats each order once per line. An order-level revenue column is then summed once per line. Aggregate the line table to one row per order before the join.

Does SELECT DISTINCT fix duplicate rows after a many-to-many join?

DISTINCT fixes it only by accident: it removes rows identical in every selected column, hiding fan-out in one query and dropping real rows in the next. Fix the grain instead, by pre-aggregating or adding a bridging table between the two sides. You can also run a check on exported Fabric tables to find the keys that are not unique.

All articles · This article as Markdown