How to manage, validate, test, and deploy LookML projects in a team environment.
Looker projects are backed by Git. Follow this branching strategy:
main (production)
├── develop (staging / QA)
│ ├── feature/add-marketing-attribution
│ ├── feature/new-customer-segments
│ └── fix/subscription-churn-calc
feature/{ticket-id}-short-descriptionfix/{ticket-id}-what-was-brokenrefactor/{area}-{what-changed}1. Create a branch from main in Looker IDE (Develop → New Branch)
2. Make changes in Development Mode
3. Validate LookML (Develop → Validate LookML) — fix all errors
4. Run Content Validator — checks for broken references in dashboards/looks
5. Test in Dev Mode — run explores, verify data accuracy
6. Open Pull Request — request review from analytics team
7. Review & Merge — reviewer checks logic, naming, performance
8. Deploy to Production — merge to main triggers production update
Before opening a PR, verify:
sql_on references resolve correctlyEXPLAIN on complex derived table SQLSELECT * in derived tables — only needed columnsAdd data tests to verify business logic:
test: revenue_is_never_negative {
explore_source: orders {
column: total_revenue { field: orders.total_revenue }
filters: [orders.order_date: "30 days"]
}
assert: revenue_positive {
expression: ${orders.total_revenue} >= 0 ;;
}
}
test: customer_count_reasonable {
explore_source: customers {
column: count { field: customers.total_customers }
}
assert: count_above_minimum {
expression: ${customers.total_customers} > 100 ;;
}
}# In model file — use environment variable or connection name convention
connection: "production_warehouse" # production
# connection: "staging_warehouse" # uncomment for staging| Environment | Connection | Data | Who Uses It |
|---|---|---|---|
| Development | Dev warehouse or sandbox | Sample/recent data | Individual developers |
| Staging | Production warehouse (read replica) | Full production data | QA, analytics leads |
| Production | Production warehouse | Full production data | All business users |
# Restrict model to specific user groups
access_grant: finance_data {
user_attribute: department
allowed_values: ["finance", "executive"]
}dimension: salary {
type: number
sql: ${TABLE}.salary ;;
required_access_grants: [hr_only]
}access_filter: {
field: orders.region
user_attribute: allowed_regions
}| Data Size | Refresh Frequency | PDT Type |
|---|---|---|
| <1M rows | Hourly | Standard PDT |
| 1-100M rows | Daily | Persistent Derived Table |
| >100M rows | Daily | Incremental PDT |
| Real-time needed | N/A | Native Derived Table (NDT) |
derived_table: {
datagroup_trigger: revenue_daily_refresh
increment_key: "order_date"
increment_offset: 3 # rebuild last 3 days to catch late-arriving data
sql:
SELECT
DATE(order_timestamp) AS order_date,
SUM(order_total) AS daily_revenue
FROM analytics.orders
WHERE DATE(order_timestamp) >= {% date_start order_date %}
GROUP BY 1
;;
}i__looker → HistoryWhen removing or renaming fields:
1. Add deprecation notice to the description:
dimension: old_field_name {
description: "DEPRECATED: Use new_field_name instead. Will be removed 2025-03-01."
hidden: yes # Hide from field picker
}2. Run Content Validator to find all references
3. Update dashboards/looks that use the old field
4. Wait one release cycle (give users time to update saved content)
5. Remove the field in the next release
Consistent naming makes a Looker project navigable by anyone on the team. Follow these rules.
| Convention | Pattern | Example |
|---|---|---|
| Table-based views | Plural noun matching source table | orders, customers, events |
| Derived tables | Descriptive purpose | customer_lifetime_value, daily_revenue_summary |
| Extension views | Describe what they add | common_dimensions, time_comparison_fields |
| Refinement files | {domain}_refinements | revenue_refinements.lkml |
File naming: snake_case.view.lkml — one view per file.
| Type | Prefix/Pattern | Example |
|---|---|---|
| Primary key | {table_singular}_id | order_id, customer_id |
| Foreign key | {related_table_singular}_id | customer_id (in orders view) |
| Date group | Descriptive verb/noun | order, created, shipped |
| Boolean | is_* or has_* | is_active, has_subscription, is_first_order |
| Tier/Bucket | {field}_tier or {field}_bucket | order_total_tier, age_bucket |
| Derived/Calculated | Descriptive name | days_since_last_order, lifecycle_stage |
| Sort helper | {field}_sort | company_size_sort |
Avoid: Prefixing dimensions with the view name (order_order_id is redundant).
| Type | Pattern | Example |
|---|---|---|
| Sum | total_{noun} | total_revenue, total_orders |
| Count | total_{plural_noun} or {noun}_count | total_customers, order_count |
| Count Distinct | unique_{plural_noun} | unique_customers, unique_sessions |
| Average | average_{noun} or avg_{noun} | average_order_value |
| Ratio/Rate | {noun}_rate or {noun}_pct | churn_rate, conversion_rate, discount_pct |
| Comparison | {metric}_prior_{period} | revenue_prior_year, orders_prior_quarter |
| Growth | {metric}_{period}_growth | revenue_yoy_growth, users_mom_growth |
| Convention | Pattern | Example |
|---|---|---|
| Primary explore | {primary_view} | orders, events |
| Scoped explore | {domain} - {focus} (in label) | Label: "Revenue - Subscriptions" |
| Group label | Domain name | group_label: "Revenue Analytics" |
One model per business domain:
revenue_analytics.model.lkmlmarketing_analytics.model.lkmlproduct_analytics.model.lkmlcustomer_analytics.model.lkmlPattern: {domain}_{frequency}_refresh
Examples:
revenue_daily_refreshevents_hourly_refreshmarketing_daily_refresh| Element | Pattern | Example |
|---|---|---|
| Parameter | {what_it_controls} | date_granularity, metric_selector |
| Templated filter | {field}_filter | date_filter, region_filter |
| Liquid variable | {% parameter param_name %} | Lowercase, snake_case |
Good description:
"Total revenue from completed orders after discounts, before tax."
Bad description:
"Sums the order_total field."
project/
├── models/ # One .model.lkml per domain
├── views/
│ ├── {domain}/ # Grouped by business domain
│ └── shared/ # Cross-domain views (calendar, common)
├── explores/ # If explores are defined separately
├── derived_tables/ # PDTs and NDTs in their own files
├── refinements/ # Refinement files (never modify originals)
├── dashboards/ # LookML dashboards
└── tests/ # Data tests
| Anti-Pattern | Why It's Bad | Do This Instead |
|---|---|---|
| One massive view file | Hard to review, merge conflicts | Split into logical views |
| Measures without descriptions | Users don't know what to pick | Always add description |
hidden: no on everything | Cluttered field picker | Hide IDs, sort keys, raw SQL helpers |
| Hardcoded SQL in explores | Breaks when schema changes | Use ${} references and substitution |
| PDTs without datagroup triggers | Stale data, manual rebuilds | Always set datagroup_trigger |
Using type: string for numbers | Breaks sorting and aggregation | Use proper type |
| Dimension named same as view | Confusing references | Be specific: order_status not just status |