+1 (726) 227-2971

Explore Guardrails in LookML: sql_always_where, always_filter, conditionally_filter, and required_fields

Ask a Looker admin where their warehouse bill comes from and you will usually hear the same story: a handful of Explores where anyone can drag a dimension onto a five-year fact table with no date filter and press Run. Aggregate awareness, BI Engine, and datagroup caching all help, but they help after the query has been written. Guardrails work earlier: they make the unbounded query impossible to build in the first place.

This tutorial covers the LookML parameters that constrain what an Explore will run, when to reach for each one, and the mistakes that quietly break dashboards.

The four tools, in one paragraph

sql_always_where injects a WHERE clause the user cannot see or remove. always_filter forces a filter into the UI with a default value the user can change. conditionally_filter requires the user to pick at least one filter from a list, and applies a default until they do. required_fields ties an expensive field to the fields it needs to be meaningful. They are not interchangeable, and choosing the wrong one is how you end up with either silently wrong numbers or an Explore nobody can use.

sql_always_where: the invisible constraint

Use sql_always_where for facts that are never legitimately part of a query — soft-deleted rows, test accounts, internal orders.

explore: orders {
  sql_always_where: ${orders.is_deleted} = false
    AND ${orders.is_internal_test} = false ;;
}

Because the clause is invisible in the UI, treat it as part of the semantic contract of the Explore, not as a performance trick. Two rules keep it safe:

  1. Document it in the Explore description. If a user exports 1.2M orders and finance's ledger says 1.3M, the missing rows must be explainable without reading LookML.
  2. Do not use it for partition pruning on its own. sql_always_where: ${created_date} >= '2024-01-01' looks like a cheap win until someone builds a year-over-year report and cannot understand why 2023 is empty.

For partition pruning you want a default, not a hard rule — that is what the next two parameters are for.

always_filter: a default the user can override

always_filter adds a filter to every query on the Explore and pre-fills it. The user can change it; they simply cannot make it disappear.

explore: order_items {
  always_filter: {
    filters: [order_items.created_date: "90 days"]
  }
}

This is the right default for high-volume event and transaction Explores on a partitioned table: the 90-day window prunes partitions on every ad-hoc query, and the analyst who genuinely needs three years just types 3 years.

Two gotchas worth knowing before you roll this out:

  • Existing dashboards. If a saved Look or dashboard tile already sets created_date, its value wins. But a tile that never had that filter will suddenly acquire one, and its numbers will change. Audit affected content with System Activity (History → filter on the Explore) before merging.
  • Merged results and embeds. An always_filter propagates into embedded queries too. If you embed a tile that is supposed to show all-time totals, set the filter explicitly in the embed URL rather than assuming the default is harmless.

conditionally_filter: "filter by something, I don't care what"

always_filter pins one specific field. Often what you actually want is: this Explore must be narrowed by at least one selective predicate. That is conditionally_filter.

explore: web_events {
  conditionally_filter: {
    filters: [web_events.event_date: "7 days"]
    unless: [
      web_events.session_id,
      web_events.user_id,
      web_events.event_date,
      web_events.account_id
    ]
  }
}

Read it as: apply a 7-day date filter unless the user has already filtered on session, user, date, or account. Someone investigating a single session gets their full history; someone browsing gets a bounded window. This is usually the most user-friendly guardrail for clickstream and log Explores, and the one consultants most often find missing.

The unless list is the part to get right. Only include fields that are genuinely selective — an indexed or clustered column, a tenant key, an entity id. Putting country in the unless list technically satisfies the rule while still scanning everything.

required_fields: expensive fields that need context

required_fields sits on a field, not on the Explore, and pulls other fields into the query whenever it is selected.

measure: conversion_rate {
  type: number
  sql: 1.0 * ${conversions} / NULLIF(${sessions}, 0) ;;
  value_format_name: percent_2
  required_fields: [sessions, conversions]
}

There are two distinct uses. The first is correctness: a ratio that is meaningless without its numerator and denominator visible. The second is performance: a field backed by an expensive join or a window function that should only ever be computed alongside the key that makes it selective.

Be aware that required_fields adds columns to the result set, which changes the grain a user sees. If you only want the join to exist without showing the extra column, prefer a hidden: yes helper dimension in the required list.

Guarding the joins, not just the filters

Filters are half the story. The other half is preventing joins that fan out or scan needlessly:

explore: orders {
  join: order_items {
    relationship: one_to_many
    sql_on: ${orders.id} = ${order_items.order_id} ;;
  }

  join: shipping_events {
    relationship: one_to_many
    sql_on: ${orders.id} = ${shipping_events.order_id} ;;
    fields: [shipping_events.latest_status, shipping_events.count]
  }
}

The fields parameter on a join exposes only a curated subset of the joined view, which keeps the field picker honest and stops users from pulling raw event columns that force a full scan. Combined with symmetric aggregates, it is the difference between an Explore people trust and one that quietly double-counts revenue.

A rollout order that does not break things

When we add guardrails to an existing instance, we work in this sequence:

  1. Measure first. Use System Activity to list the slowest and most expensive queries per Explore over the last 30 days, and note which ones ran with no date filter.
  2. Start with conditionally_filter. It is the least disruptive: saved content that already filters is untouched.
  3. Add sql_always_where only for true row exclusions, and write the exclusion into the Explore description in the same commit.
  4. Run your data tests and Spectacles suite on the branch. Guardrails change result sets, so a passing suite that asserts row counts is your early warning.
  5. Announce the default windows. A one-line release note in the dashboard folder prevents a week of "the numbers changed" tickets.

Testing guardrails in LookML

Guardrails are assertions about your model, so assert them:

test: orders_excludes_test_accounts {
  explore_source: orders {
    column: count {}
    filters: [orders.is_internal_test: "yes"]
  }
  assert: no_internal_orders {
    expression: ${orders.count} = 0 ;;
  }
}

Run it in CI on every pull request. If someone removes the sql_always_where clause during a refactor, the build fails instead of the finance report.

Where guardrails end

Guardrails bound the query; they do not make a badly modeled Explore fast. If your 90-day default still takes forty seconds, the next moves are aggregate awareness, an incremental PDT, or a clustered table — not a tighter filter. Think of these parameters as the floor of a performance practice: cheap to add, hard to regret, and the first thing worth checking on any Looker instance whose warehouse spend is climbing faster than its usage.

If you would like a second pair of eyes on an instance whose Explores have outgrown their guardrails, Vistelio's senior Looker developers do exactly this kind of review — get in touch.