Labs4Change

SQL Fanouts in Looker: Why Your Measures Are Wrong and How to Fix Them

Understand SQL fanouts in Looker — why joined tables produce incorrect counts and sums, how to detect fanouts, and three patterns to prevent them.

You built a dashboard. The total revenue number looks right. Then someone joins a second table and suddenly revenue doubles. Welcome to the fanout problem — the most common source of incorrect data in Looker, and the hardest to detect because nothing actually breaks.


What Is a Fanout?

A fanout happens when a join creates duplicate rows. When you aggregate on those duplicated rows, your counts and sums are wrong.

Here's a concrete example.

Customers table:

idnamevisits
1Amelia2
2Charles2
3Wilbur4

Orders table:

idcustomer_idamount
1125.00
2150.00
3275.00
43100.00

If we join customers to orders:

SELECT *
FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id

We get:

customer_namevisitsorder_idamount
Amelia2125.00
Amelia2250.00
Charles2375.00
Wilbur44100.00

Now SUM(visits) returns 10 instead of the correct 8. Amelia's 2 visits got counted twice because she has 2 orders. That's a fanout.


The Three Join Types and Their Risk

1. One-to-One (Safe)

One customer has one DNA record. No duplication possible.

explore: customers {
  join: dna_records {
    relationship: one_to_one
    sql_on: ${customers.id} = ${dna_records.customer_id} ;;
  }
}

2. Many-to-One (Safe)

Many orders belong to one customer. Starting from orders and joining customers won't duplicate order rows.

explore: orders {
  join: customers {
    relationship: many_to_one
    sql_on: ${orders.customer_id} = ${customers.id} ;;
  }
}

3. One-to-Many (Dangerous)

One customer has many orders. Starting from customers and joining orders will duplicate customer rows.

explore: customers {
  join: orders {
    relationship: one_to_many  # This causes fanouts on customer measures
    sql_on: ${customers.id} = ${orders.customer_id} ;;
  }
}

The rule: aggregations on the "one" side of a one-to-many join are almost always wrong.


How to Detect Fanouts

The simplest test: count rows before and after the join.

-- Before join
SELECT COUNT(*) FROM customers;  -- Returns 3

-- After join
SELECT COUNT(*) FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id;  -- Returns 4

If the count increases, you have a fanout. In Looker, you can verify by:

  1. Running a query with just customers.count — note the number
  2. Adding any field from the joined orders table
  3. If customers.count changes, you have a fanout

Three Patterns to Prevent Fanouts

Pattern 1: Start From the Right Base Table

The easiest fix is choosing the correct base table for your Explore. If you need order-level analysis with customer attributes, start from orders:

explore: orders {
  join: customers {
    type: left_outer
    relationship: many_to_one
    sql_on: ${orders.customer_id} = ${customers.id} ;;
  }
}

Now customer attributes are available without duplicating rows, because each order matches exactly one customer.

Pattern 2: Pre-Aggregate in dbt

When you need customer-level metrics alongside order-level metrics, pre-aggregate the order data in dbt:

-- dbt model: customer_order_summary
SELECT
  customer_id,
  COUNT(*) AS total_orders,
  SUM(amount) AS total_revenue,
  MIN(created_at) AS first_order_date,
  MAX(created_at) AS last_order_date
FROM orders
GROUP BY customer_id

Then join this summary table instead of the raw orders table:

explore: customers {
  join: customer_order_summary {
    type: left_outer
    relationship: one_to_one
    sql_on: ${customers.id} = ${customer_order_summary.customer_id} ;;
  }
}

One-to-one join, no fanout, correct aggregations.

Pattern 3: Use type: count_distinct Instead of type: count

If you must use a one-to-many join, at minimum use count_distinct instead of count:

measure: customer_count {
  type: count_distinct
  sql: ${customer_id} ;;
  # Instead of type: count, which counts all rows including duplicates
}

This won't fix SUM fanouts, but it fixes the most common incorrect metric.


Real-World Example: The Triple Fanout

We audited a Looker instance where an Explore joined customers → orders → order_items → shipments. Three one-to-many joins deep. The total_customers measure was 47x higher than the actual number.

Nobody noticed for months because the dashboard only showed percentage-based metrics — and the percentages happened to look reasonable.

The fix: We restructured into three Explores, each starting from the correct base table, with pre-aggregated summary tables for cross-domain metrics. Total audit and fix time: 2 days. The client had been building dashboards on wrong data for over a year.


A Decision Framework

QuestionAnswerAction
Does the join increase row count?NoSafe — no fanout
Does the join increase row count?YesFanout risk — check measures
Do you aggregate on the "one" side?NoNo impact even with fanout
Do you aggregate on the "one" side?YesFix required
Can you change the base table?YesPattern 1: Flip the Explore
Is the data available in dbt?YesPattern 2: Pre-aggregate
Neither option works?Pattern 3: Use count_distinct + document the limitation

Key Takeaways

  1. Fanouts are silent. Nothing breaks — numbers are just wrong.
  2. Always verify row counts before and after joins during development.
  3. Start from the "many" side when building Explores.
  4. Pre-aggregate in dbt when you need metrics from both sides of a one-to-many relationship.
  5. Declare relationships correctly in your LookML — many_to_one vs one_to_many matters for Looker's symmetric aggregation logic.

Get the LookML Best Practices Guide + AI Skill

Our guide covers fanouts, caching, permissions, project structure, and more — plus a Claude skill that detects fanout risks in your LookML.


Labs4Change has trained 16,000+ analytics engineers on Looker. Book a free LookML audit to find out if your Explores have fanout issues.