looker-expressions)
Context matrix: Table Calculations vs. Custom Fields vs. LookML Expressions
Looker expressions run in two different execution engines—either pushed down into warehouse SQL or evaluated post-query in memory over returned rows. Single-field shorthand strings ("this year", "-Cancelled", ">=30") are Filter Expressions, not Looker expressions; see looker-filters.
| Context | Where authored | Execution engine | Allowed ${...} references |
matches_filter() |
Positional, pivot, column-agg & to_* functions |
|---|---|---|---|---|---|
| Table Calculations | run_inline_query / create_query body["dynamic_fields"], Explore UI, .dashboard.lookml |
Post-query in-memory (over displayed rows up to limit) |
Dimensions, measures, other table calcs, :total, :row_total (all must be in query) |
No | Yes (offset, offset_list, index, match, running_total, sum, mean, pivot_*, to_number, etc.) |
| Custom Dimensions | run_inline_query / create_query body["dynamic_fields"] ("dimension"), Explore UI, .dashboard.lookml |
SQL pushdown (SELECT / GROUP BY) |
Modeled dimensions (${view.dim}) |
Yes (backticks) | No |
| Custom Measures | run_inline_query / create_query body["dynamic_fields"] ("measure"), Explore UI, .dashboard.lookml |
SQL pushdown (AGG(CASE WHEN ...) via filter_expression) |
based_on (hidden: no field) + dimensions in filter_expression |
Yes (backticks) | No |
| Custom Filters | run_inline_query / create_query body["filter_expression"], Explore UI |
SQL pushdown (WHERE, must return yes/no) |
Dimensions only (never measures, totals, or table calcs) | Yes (backticks) | No |
LookML Data Test assert: |
tests/**/*.test.lkml (assert: { expression: ... ;; }, run via run_lookml_test) |
Post-query in-memory (must return yes on every row; 5,000-row cap) |
Fully scoped ${view.field} projected via column: in explore_source: |
No | Yes (same engine & function set as Table Calculations) |
LookML NDT expression_custom_filter: |
.view.lkml (explore_source: { expression_custom_filter: ... ;; }) |
SQL pushdown (WHERE inside NDT subquery) |
Dimensions only from explore_source |
Yes (backticks) | No (NDT derived_column: { sql: ... ;; } uses warehouse SQL, not Looker expressions) |
Quick Start boundary: Looker's
explore: { query: {} }Quick Start block (agentic-lookml) accepts only modeleddimensions,measures,filters(looker-filters),pivots,subtotals,limit,sorts, andtimezone—never Table Calculations or Custom Fields.
Universal syntax rules & SQL translation traps
- Function names are strictly lowercase:
round(${x}, 2),if(...),is_null(...)(ROUND(...)fails). - Logical operators are strictly UPPERCASE:
AND,OR,NOT(and/or/notfail).ANDbinds tighter thanOR; use parentheses to override. - Logical constants are lowercase unquoted symbols:
yes,no,null(nevertrue/false). Unquotedyes/noare booleans; quoted"yes"/"no"are strings. Atype: yesnofield (${orders.is_returned}) already evaluates toyes/no—write${orders.is_returned}orNOT ${orders.is_returned}, and write${x} > 100directly instead ofif(${x} > 100, yes, no). - Decimals
< 1require a leading zero:0.95works;.95throws a syntax error. - Strings & comments: Wrap string literals in double quotes (
"Complete", never single quotes). Start line comments with#.
| SQL habit (fails or wrong) | Correct Looker expression | Trap details |
|---|---|---|
x / NULLIF(y, 0) |
if(${y} = 0, null, ${x} / ${y}) |
nullif() does not exist in Looker expressions. |
CASE WHEN c1 THEN v1 ELSE v2 END |
case(when(c1, v1), when(c2, v2), v2) or if(c1, v1, v2) |
SQL CASE WHEN ... END syntax is invalid. |
a || ' ' || b or a + b |
concat(${users.first_name}, " ", ${users.last_name}) |
+ is numeric addition only. |
POW(x, 2) or x ^ 2 |
power(${x}, 2) |
^ is not a math operator (+, -, *, / only). |
CEIL(x) |
ceiling(${x}) |
Function name is ceiling, not ceil. |
DATE_ADD(d, INTERVAL 7 DAY) |
add_days(7, ${orders.created_date}) |
Number argument comes first, date second (add_days(number, date)). |
DATE_DIFF(end_d, start_d, DAY) |
diff_days(${orders.created_date}, ${orders.delivered_date}) |
start_date comes first, end_date second (end - start). |
SUBSTR(s, 1, 3) |
substring(${products.sku}, 1, 3) |
start_position is 1-indexed (1 = first character). |
s ILIKE '%hoodie%' |
contains(lower(${products.name}), "hoodie") |
contains(string, search_string) is case-sensitive. |
1. Table Calculations (post-query in-memory)
Table Calculations operate on the result table after the SQL query finishes (02-conditional-measures-and-bucketing.md, 07-measure-of-measures-reaggregation.md, 08-window-functions-and-qualify.md):
- Field selection requirement: Every
${view.field}referenced in a Table Calculation must be selected in the query'sfields(ordynamic_fields). Do not list thetable_calculationname itself insidebody["fields"](unlike Custom Fields). - Row-limit boundary: Column functions (
sum(),mean(),rank(),running_total()) only see returned rows (e.g., top 500 if"limit": "500"). Use${orders.total_revenue:total}(with"total": Trueinrun_inline_query/create_querybody) when you need the true SQL grand total across truncated rows. - Sorting constraints: Database sorts must come before Table Calculation sorts (
"sorts": ["orders.total_net_revenue desc", "pct_of_total desc"]). Looker disables sorting on any Table Calculation if the formula usesoffset()or if the query hit its row limit. - Post-query visual row filtering (
Hide "No"s from Visualization): A Table Calculation returningyes/no(e.g.,NOT is_null(${wow_growth})or${wow_growth} > 0) can hideNorows from charts without re-running SQL.
Common Table Calculation patterns
# Safe display ratio between two measures (02-conditional-measures-and-bucketing.md)
if(${uk_orders} = 0, null, ${us_orders} / ${uk_orders})
# Percent of displayed column vs. percent of SQL grand total
${orders.total_revenue} / sum(${orders.total_revenue})
${orders.total_revenue} / ${orders.total_revenue:total}
# Running total & average of displayed daily values (07 & 08 playbooks)
running_total(${orders.total_revenue})
mean(${orders.total_revenue})
# Period-over-period % change (when sorted DESC: previous period is row +1; when sorted ASC: row -1)
(${orders.total_revenue} / offset(${orders.total_revenue}, 1)) - 1
(${orders.total_revenue} - offset(${orders.total_revenue}, -1)) / if(offset(${orders.total_revenue}, -1) = 0, null, offset(${orders.total_revenue}, -1))
# 7-row trailing moving average (sorted ASC)
mean(offset_list(${orders.total_revenue}, -6, 7))
# Pivoted column-over-column % change & % of row
(${orders.total_revenue} / pivot_offset(${orders.total_revenue}, -1)) - 1
${orders.total_revenue} / sum(pivot_row(${orders.total_revenue}))
Advanced index(), match(), and offset_list() patterns (live-verified)
index(col, row_num) returns the value at 1-based row_num, while match(val, col) returns the 1-based row number of the first occurrence of val in col (or, when col is a column of lists from offset_list(), the 1-based position inside each row's list). Combining them with row(), max(row()), and offset_list() emulates complex Excel INDEX/MATCH lookups and SQL OVER (PARTITION BY ...) window functions purely in memory:
-
Single-key & global peak (
ARGMAX)INDEX/MATCH:# Benchmark every row against a named cohort row (survives re-sorting) ${order_items.count} / index(${order_items.count}, match("Jeans", ${products.category})) # Return the dimension label of the highest-volume row across the table index(${products.category}, match(max(${order_items.count}), ${order_items.count})) -
Multi-criteria composite
INDEX/MATCH(match(yes, cond_1 AND cond_2)): Passing a compound boolean expression as the second argument ofmatch()evaluates a virtualyes/nocolumn and returns the first row where all predicates hold (equivalent to ExcelINDEX(C:C, MATCH(1, (A:A="Women")*(B:B="Jeans"), 0))):index(${order_items.count}, match(yes, ${products.department} = "Women" AND ${products.category} = "Jeans")) -
Simulating
OVER (PARTITION BY dim)subtotals, running totals,LAST_VALUE, and partitionedARGMAX: Sort the query primarily by the partition dimension ("sorts": ["products.department asc", ...]). Becausematch()only finds the first match, reversing the column viaoffset(${dim}, max(row()) - row() * 2 + 1)lets a secondmatch()locate the last row of each partition (usingmax(row())rather thancount(${dim})sonullvalues never shift indices):# Helper 1 — group_start: 1-based first row of current department partition match(${products.department}, ${products.department}) # Helper 2 — group_len: exact row count in current department partition (max(row()) - match(${products.department}, offset(${products.department}, max(row()) - row() * 2 + 1)) + 2) - ${group_start} # Partitioned subtotal: SUM(count) OVER (PARTITION BY department) sum(offset_list(${order_items.count}, -(row() - ${group_start}), ${group_len})) # Partitioned running total: SUM(count) OVER (PARTITION BY department ORDER BY category ASC) sum(offset_list(${order_items.count}, -(row() - ${group_start}), row() - ${group_start} + 1)) # Partitioned LAST_VALUE: last category in each department partition index(${products.category}, ${group_start} + ${group_len} - 1) # Partitioned ARGMAX under arbitrary sort: top-selling category inside each department # even when sorted alphabetically (match(val, offset_list(...)) returns 1-based index within each row's slice!) index( ${products.category}, ${group_start} + match( max(offset_list(${order_items.count}, -(row() - ${group_start}), ${group_len})), offset_list(${order_items.count}, -(row() - ${group_start}), ${group_len}) ) - 1 ) -
Conditional moving average over the last $N$ qualifying rows (skipping non-matching rows): When standard
offset_list(..., -2, 3)fails because intermediate rows fail a condition (count < 50), number qualifying rows withrunning_total()and usematch()to jump back dynamically to the $(k - N + 1)$-th qualifying row (mean()ignoresnulls inside the spanned slice):# Helper — qualifying_seq: running count of qualifying rows running_total(if(${order_items.count} >= 50, 1, 0)) # Average of the most recent 3 rows where count >= 50 (regardless of how many <50 rows sit between them) if( ${qualifying_seq} < 3, null, mean( offset_list( if(${order_items.count} >= 50, ${order_items.count}, null), -(row() - match(${qualifying_seq} - 2, ${qualifying_seq})), row() - match(${qualifying_seq} - 2, ${qualifying_seq}) + 1 ) ) )
Running Table Calculations via Looker SDK (run_inline_query / create_query)
Pass "dynamic_fields" as a JSON-encoded string on run_inline_query(result_format="json", body=...) or create_query(body=...) (lkr-code-mode):
import json
body = {
"model": "agentic_thelook",
"view": "orders",
"fields": ["orders.created_month", "orders.total_net_revenue"],
"sorts": ["orders.created_month asc"],
"limit": "500",
"dynamic_fields": json.dumps([
{
"table_calculation": "running_revenue",
"label": "Running Revenue",
"expression": "running_total(${orders.total_net_revenue})",
"value_format": None,
"value_format_name": "usd",
}
]),
}
rows = json.loads(run_inline_query(result_format="json", body=body))
2. Custom Fields & Custom Filters (SQL pushdown)
Custom Fields and Custom Filters compile directly into the warehouse SQL query (SELECT, GROUP BY, WHERE) without modifying LookML files. Promote them to bronze/:table.refinement.lkml (single-table DAG) or silver/:view_a_:view_b_fields.view.lkml (cross-view DAG) when reused across reports or defined as canonical metrics/terms (agentic-lookml).
Custom Dimensions & Custom Measures (body["dynamic_fields"])
- Must appear in both
dynamic_fieldsANDfields: Every Custom Dimension ("dimension") or Custom Measure ("measure") defined inbody["dynamic_fields"]onrun_inline_query/create_query(orcreate_dashboard_element/update_dashboard_element) must also list its identifier inbody["fields"]in projection order. based_onvisibility rule: Custom Measures (count_distinct,sum,average,min,max,median,list, or filtered existing measures) require"based_on"pointing to ahidden: noLookML field.matches_filterwith backticks: Embed looker-filters shorthand inside Custom Dimensions, Custom Measurefilter_expressions, or Custom Filters using backticks (`...`):matches_filter(${orders.status}, `Complete,Shipped`) AND matches_filter(${orders.created_date}, `this year`)- (Non-expression Custom Field shortcuts: Custom Groups and Custom Bins bucket dimension values or numeric tiers without writing
case()formulas.)
import json
body = {
"model": "agentic_thelook",
"view": "orders",
"fields": ["orders.created_date", "age_cohort", "us_completed_revenue"],
"dynamic_fields": json.dumps([
{
"dimension": "age_cohort",
"label": "Age Cohort",
"expression": 'case(when(${users.age} < 25, "Gen Z"), when(${users.age} <= 40, "Millennial"), "40+")',
},
{
"measure": "us_completed_revenue",
"label": "US Completed Revenue",
"based_on": "orders.sale_price",
"type": "sum",
"filter_expression": 'matches_filter(${users.country}, `US`) AND (${orders.status} = "Complete" OR ${orders.is_override})',
"value_format_name": "usd",
},
]),
"sorts": ["orders.created_date desc"],
"limit": "500",
}
rows = json.loads(run_inline_query(result_format="json", body=body))
Explore Custom Filters (body["filter_expression"])
Standard body["filters"] (looker-filters) compares one field at a time against constants and joins multiple fields with AND. Pass body["filter_expression"] on run_inline_query / create_query (a Looker expression returning yes/no, dimensions only) whenever SQL WHERE needs:
- Field-to-field comparisons across joined views (e.g., filtering orders or events to "New Customers" where activity year matches signup year):
${orders.created_year} = ${users.created_year} - Cross-column
ORconditions inWHERE:${orders.status} = "Complete" OR ${orders.is_override}
3. LookML Expressions (test: assert: vs. NDT expression_custom_filter:)
LookML files embed Looker expressions (terminated by ;;) in two distinct parameters with different engines:
A. LookML Data Tests: test: { assert: { expression: ... ;; } } (in-memory)
Defined in tests/bronze/*.test.lkml and tests/silver/*.test.lkml and executed via Looker SDK run_lookml_test(project_id=..., file_id=..., test=...) (lookml-tests, agentic-lookml):
- Uses the post-query Table Calculation engine (all functions except
matches_filter()are supported;explore_source:caps results at5,000rows). - Every
${view.field}inexpression:must be fully scoped (${orders.count}, never${count}) and projected as acolumn:inexplore_source:. - Passes only if
expression:evaluates toyesfor every row returned byexplore_source:.
# Immutable historical slice equality + rounding (tests/bronze/order_items_base.test.lkml)
test: order_items_base_2024_01_01_metrics {
explore_source: order_items_base {
column: total_sale_price { field: order_items.total_sale_price }
column: count { field: order_items.count }
filters: [order_items.created_date: "2024-01-01"]
}
assert: total_sale_price_matches {
expression: round(${order_items.total_sale_price}, 2) = 4520.25 ;;
}
assert: order_item_count_matches {
expression: ${order_items.count} = 84 ;;
}
}
# Open-state algebraic invariants (never assert frozen constants on open states like is_sold: "no")
test: inventory_open_state_invariants {
explore_source: inventory_items {
column: sunk_dusty_shelf_cost { field: inventory_items.sunk_dusty_shelf_cost }
column: total_cost { field: inventory_items.total_cost }
filters: [inventory_items.created_date: "2024-01-01"]
}
assert: sunk_cost_bounded_by_total {
expression: NOT is_null(${inventory_items.sunk_dusty_shelf_cost})
AND ${inventory_items.sunk_dusty_shelf_cost} >= 0
AND ${inventory_items.sunk_dusty_shelf_cost} <= ${inventory_items.total_cost} ;;
}
}
B. Native Derived Table expression_custom_filter: (SQL pushdown)
Inside derived_table: { explore_source: ... } (lookml-view derived_table), use filters: (looker-filters) for single-field predicates and expression_custom_filter: (dimensions only, SQL pushdown) when the NDT WHERE clause compares two dimensions or combines dimensions with OR:
explore_source: order_items {
column: user_id { field: order_items.user_id }
column: first_year_revenue { field: order_items.total_sale_price }
expression_custom_filter: ${order_items.created_year} = ${users.created_year} ;;
}
(By contrast, derived_column: { sql: RANK() OVER (ORDER BY first_year_revenue DESC) ;; } inside explore_source: uses warehouse SQL over inner column aliases—not Looker expressions.)
4. Function and operator summary
- All contexts (SQL pushdown & in-memory):
- Logical & branching:
=,!=,<,<=,>,>=,AND,OR,NOT,yes,no,null,if(cond, if_yes, if_no),case(when(c1, v1), when(c2, v2), else_v),coalesce(v1, v2, ...),is_null(x). - Math & string:
+,-,*,/(guardif(b = 0, null, a / b)),abs,ceiling,floor,round(x, digits),sqrt,power(base, exp),mod(x, div),exp,ln,log,rand(),concat(s1, s2, ...),contains(str, needle)(case-sensitive),position(str, needle),length,lower,upper,replace(str, old, new),substring(str, start_1based, len). - Date & time:
now(),date(y, m, d),date_time(y, m, d, h, min, s),add_days/add_months/add_years/add_hours/add_minutes/add_seconds(n, d)(nfirst),diff_days/diff_months/diff_years/diff_hours/diff_minutes/diff_seconds(start_d, end_d)(end - start),extract_*,trunc_*.
- Logical & branching:
- SQL pushdown ONLY (Custom Filters, Custom Fields, NDT
expression_custom_filter:):matches_filter(${view.field}, `filter_expr`). - Post-query in-memory ONLY (Table Calculations &
.test.lkmlassert:):${field:total},${field:row_total},row(),offset(col, k),offset_list(col, k, len),index(col, n),match(val, col),lookup(val, lookup_col, result_col),list(...),pivot_column(),pivot_index,pivot_offset,pivot_offset_list,pivot_row,pivot_where,sum,running_total,mean,median,min,max,count,count_distinct,rank,percentile,to_number,to_string,to_date,split. - Full signature notes, statistical/distribution functions, and 1:1 Microsoft Excel equivalents: see references/excel-equivalents.md.