Labs4Change

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

David Badovinac

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.

Keep reading