dbt vs Looker: Where Should Your Business Logic Live?
A decision framework for what belongs in dbt and what belongs in Looker. Stop duplicating logic across your data stack — here's where each transformation should go.
Every team using dbt and Looker together hits the same question: where does the business logic go? Do you calculate revenue in a dbt model or a LookML measure? Do you define "active customer" in SQL or in an Explore filter?
The wrong answer leads to duplicated logic, conflicting numbers, and a data team spending half their time debugging discrepancies. Here's the framework we use across every engagement.
The Core Principle: Single Source of Truth
If a calculation can be defined once and used everywhere, it should be. The question is where "once" lives.
dbt is your transformation layer. It owns the data model — cleaning, joining, deduplicating, and structuring data into tables that any downstream tool can consume.
Looker is your presentation layer. It owns the user experience — labels, permissions, drill paths, caching, and simple aggregations that map directly to business questions.
When you put transformation logic in Looker (derived tables, complex SQL in dimensions), you've coupled your data model to your BI tool. When you put presentation logic in dbt (field labels, format strings), you've coupled your data model to a specific consumer.
What Belongs in dbt
Data Transformations
- Table joins and relationships
- Data cleaning and standardization
- Type casting and conversions
- Data deduplication
- Window functions
- Historical data handling (SCD Type 2, snapshots)
Business Logic
- Core business rules (e.g., "what counts as an active customer")
- Complex CASE statements
- Derived calculations (e.g., LTV, churn risk score)
- Common metric definitions
- Data flattening and nested field handling
Data Quality
- Schema tests (
not_null,unique,accepted_values) - Custom data tests
- Data validation rules
- Null handling
- Referential integrity checks
Documentation
- Table and column descriptions (these flow to BigQuery and then to Looker)
- Business logic documentation
- Data lineage
- Source documentation
Example: Defining "active customer" in dbt
-- models/marts/customers.sql
SELECT
customer_id,
email,
first_order_date,
last_order_date,
total_orders,
total_revenue,
CASE
WHEN last_order_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) THEN 'Active'
WHEN last_order_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 365 DAY) THEN 'At Risk'
ELSE 'Churned'
END AS customer_status
FROM {{ ref('stg_customers') }}
Now every tool — Looker, a Python notebook, a Streamlit app — gets the same definition of "active customer."
What Belongs in Looker
User Experience
- Field labels and descriptions (only when overriding dbt descriptions)
- Field grouping (
group_label) - Explore labels and descriptions
- Dashboard layouts
Access Control
- Row-level security (
access_filter) - Column-level security (
access_grant) - Model access and permission sets
- User attributes
Query-Time Aggregations
- Simple measures: SUM, COUNT, AVG, MIN, MAX
- Count distinct
- Running totals and percent-of-total
- Drill fields and links
Visualization
- Chart configurations
- Conditional formatting
- Drill-down behavior
- Dashboard design
Example: Simple aggregation in Looker
measure: total_revenue {
type: sum
sql: ${revenue} ;;
value_format_name: usd
drill_fields: [order_id, created_date, revenue, customer_name]
}
This is a pure aggregation — it doesn't define what "revenue" is (that's dbt's job), it just sums the column and adds formatting and drill paths.
The Gray Areas
Derived Tables: Almost Always Wrong in Looker
If you're writing a derived_table in LookML, ask yourself: could this be a dbt model? The answer is almost always yes.
# Don't do this in Looker
view: customer_order_summary {
derived_table: {
sql:
SELECT
customer_id,
COUNT(*) AS total_orders,
SUM(amount) AS total_revenue
FROM orders
GROUP BY customer_id ;;
}
}
-- Do this in dbt instead
-- models/marts/customer_order_summary.sql
SELECT
customer_id,
COUNT(*) AS total_orders,
SUM(amount) AS total_revenue
FROM {{ ref('stg_orders') }}
GROUP BY customer_id
Why dbt is better here:
- Tested with dbt tests
- Version controlled in dbt
- Materialized as a table (faster queries)
- Documented in dbt docs
- Available to other tools, not just Looker
The only valid exception: Looker-specific PDTs that handle user-facing pivots or performance optimizations that don't make sense as part of the core data model.
Descriptions: dbt Wins
Write descriptions in dbt. They flow to BigQuery column descriptions, which Looker inherits automatically:
# dbt schema.yml
models:
- name: orders
description: "All completed and pending orders"
columns:
- name: order_id
description: "Unique identifier for each order"
- name: status
description: "Current status: Processing, Shipped, or Delivered"
Don't duplicate these in LookML. Only add Looker-specific context if needed (e.g., "Filter by this field first — unfiltered queries are expensive").
Simple CASE Statements: It Depends
A simple bucketing dimension can live in Looker if it's purely for presentation:
dimension: revenue_tier {
type: string
sql: CASE
WHEN ${revenue} >= 10000 THEN 'Enterprise'
WHEN ${revenue} >= 1000 THEN 'Mid-Market'
ELSE 'SMB'
END ;;
}
But if other teams or tools need the same bucketing, it belongs in dbt.
Decision Framework
| Question | dbt | Looker |
|---|---|---|
| Is this a data transformation? | Yes | |
| Is this a business rule used by multiple tools? | Yes | |
| Does this involve joining tables? | Yes | |
| Is this a simple aggregation (SUM, COUNT)? | Yes | |
| Is this about access control or permissions? | Yes | |
| Is this about how fields appear to users? | Yes | |
| Is this a derived table? | Almost always | Rare exception |
| Is this a description? | Yes (flows to Looker) | Only for overrides |
The Anti-Patterns
1. Duplicated Revenue Definitions
-- In dbt
total_revenue = SUM(amount) WHERE status != 'cancelled'
-- In Looker (different definition!)
measure: total_revenue {
type: sum
sql: ${amount} ;; # Missing the cancelled filter
}
Now finance sees one number, the dashboard shows another. Fix: define revenue as a column in dbt with the business rule applied. Looker just sums it.
2. Complex SQL in LookML
dimension: customer_segment {
sql: CASE
WHEN ${lifetime_value} > 10000
AND ${days_since_last_order} < 30
AND ${order_count} > 5 THEN 'VIP'
WHEN ${lifetime_value} > 5000 THEN 'High Value'
WHEN ${days_since_last_order} > 365 THEN 'Churned'
ELSE 'Standard'
END ;;
}
This is business logic that belongs in dbt. What happens when the marketing team wants to use customer segments in their email tool? They can't — it's locked in Looker.
3. Presentation Logic in dbt
-- Don't put this in dbt
SELECT
revenue,
CONCAT('$', FORMAT(revenue, 2)) AS formatted_revenue,
'Revenue' AS field_label
FROM orders
Formatting and labeling are Looker's job.
Getting Started
If your Looker instance has derived tables and complex SQL scattered across LookML files, here's the migration path:
- Inventory your derived tables — list every
derived_tablein your LookML project - Identify the dbt candidates — anything that joins, transforms, or applies business rules
- Build the dbt models — one model per derived table, tested and documented
- Update LookML — point views at the new dbt-materialized tables
- Delete the derived tables — once validated, remove them from LookML
We've done this migration dozens of times. The typical result: 40-60% fewer lines of LookML, faster query performance (dbt materializes tables vs. Looker's runtime SQL), and a single source of truth for every metric.
Get the LookML Best Practices Guide + AI Skill
Our guide covers the dbt/Looker boundary, project structure, caching, and more — the patterns we've refined across 200+ implementations.
Labs4Change builds modern data stacks with dbt, BigQuery, and Looker. Book a free architecture review — we'll audit where your business logic lives and how to clean it up.