+1 (726) 227-2971

Designing Drill Paths in Looker: drill_fields, links, and Liquid Drill Menus

Every Looker rollout eventually hits the same question from a business user: "that number looks wrong, what is in it?" The answer to that question is a drill path, and in most LookML projects drills are an afterthought — a copied drill_fields: [id, name, created_date] that returns a hundred columns of raw table and answers nothing.

Drills are cheap to build and they deflect an enormous amount of ad-hoc analyst work. This tutorial covers how drilling actually works in Looker, how to design drill paths deliberately, and the traps (permissions, performance, filter inheritance) that make drills misbehave in production.

What a drill actually does

When a user clicks a drillable value, Looker builds a new query against the same explore. It takes the query that produced the clicked cell, adds the row's dimension values as filters, and replaces the selected fields with whatever you listed in drill_fields. Three consequences matter:

  • A drill is a fresh warehouse query, not a slice of a cached result. Wide, unaggregated drill sets cost real money.
  • The drill inherits the filters of the originating query, plus the pivot and row context of the clicked cell.
  • The drill runs in the same explore, so anything not joinable into that explore cannot appear in the drill.

Drilling on measures vs dimensions

The two cases behave differently and people conflate them constantly.

Measure drills answer "what rows make up this number?" Add drill_fields to the measure:

measure: total_revenue {
  type: sum
  sql: ${order_amount} ;;
  value_format_name: usd
  drill_fields: [order_detail*]
}

Dimension drills answer "what is the next level of detail below this attribute?" They are how you build hierarchies:

dimension: category {
  type: string
  sql: ${TABLE}.category ;;
  drill_fields: [brand, product_name, total_revenue]
}

dimension: brand {
  type: string
  sql: ${TABLE}.brand ;;
  drill_fields: [product_name, total_revenue]
}

Clicking Category gives brands, clicking a brand gives products. A dimension drill that includes a measure keeps the drill aggregated, which is almost always what the business actually wanted — not a row dump.

Use drill sets, not copy-paste lists

Repeat a field list in twelve measures and you will update eleven of them. Define reusable sets once:

set: order_detail {
  fields: [
    orders.id,
    orders.created_date,
    users.name,
    users.country,
    products.product_name,
    orders.total_revenue
  ]
}

set: customer_detail {
  fields: [users.id, users.name, users.email, users.country, orders.count]
}

Then reference them with the * suffix: drill_fields: [order_detail*]. Sets compose, so set: order_detail_plus { fields: [order_detail*, orders.status] } works and stays DRY. Sets live in the view they are declared in; reference them cross-view as view_name.set_name*.

Explore-level and default drills

You can set a fallback on the explore so that any field without its own drill_fields still drills somewhere sensible:

explore: orders {
  fields: [ALL_FIELDS*]
  join: users { ... }
}

and on dashboards, a tile's drill behavior follows the LookML of the fields it displays. If a dashboard element needs a different drill from the model default, override it in a refinement rather than editing the shared view:

include: "/views/orders.view.lkml"

view: +orders {
  measure: total_revenue {
    drill_fields: [exec_summary*]
  }
}

Refinements let a finance-facing model expose a narrow, compliant drill while the ops model keeps the full row-level drill. (See our post on refinements vs extends for the wider pattern.)

Custom drill menus with link

drill_fields gives you one destination. link gives you many, including destinations outside Looker:

dimension: order_id {
  type: number
  sql: ${TABLE}.id ;;

  link: {
    label: "Order timeline"
    url: "/dashboards/42?Order+ID={{ value }}"
    icon_url: "https://looker.com/favicon.ico"
  }

  link: {
    label: "Open in fulfilment system"
    url: "https://ops.internal.example.com/orders/{{ value }}"
  }
}

Useful Liquid variables inside url:

  • {{ value }} — the rendered value; {{ rendered_value }} and {{ filterable_value }} for the display and filter-safe forms.
  • {{ field_name._value }} — another field on the same row, e.g. {{ users.country._value }}.
  • {{ _filters['orders.created_date'] }} — the filter applied to the originating query, so a link can carry the date range through.
  • {{ _user_attributes['region'] }} — branch the link per user.
  • {{ link }} inside an html block — the default drill URL, so you can keep drilling and restyle the cell.

Guard links that only make sense for some rows with a conditional:

  link: {
    label: "Investigate refund"
    url: "{% if status._value == 'returned' %}https://ops.internal.example.com/refunds/{{ value }}{% else %}#{% endif %}"
  }

Better still, build the whole menu in an html block when you need conditional labels, and keep link for the stable destinations.

Drilling to a dashboard instead of a table

A drill to a curated dashboard beats a drill to 40 raw columns. The pattern is a link whose URL targets a LookML dashboard with filters populated from the row:

measure: total_revenue {
  type: sum
  sql: ${order_amount} ;;
  link: {
    label: "Revenue detail dashboard"
    url: "/dashboards/revenue_detail?Category={{ orders.category._value | url_encode }}&Date+Range={{ _filters['orders.created_date'] | url_encode }}"
  }
}

Always url_encode values that can contain spaces, ampersands, or commas — un-encoded category names are the single most common cause of "the drill dashboard came back empty".

Keeping drills safe

Drills bypass nothing, but they do surface things people forget are in the model:

  • Row-level security still applies. access_filter and sql_always_where are part of the explore, so the drill query carries them. Good.
  • Field-level permissions apply too. If a drill set lists a field hidden behind required_access_grants, users without the grant simply do not see that column — the drill still runs. Test this with a non-admin sudo session rather than assuming.
  • PII leaks through drill sets. A measure drill listing users.email on a dashboard shared with a vendor is an incident waiting to happen. Keep a separate pii_detail set and only reference it from models gated by an access grant.

Keeping drills fast

  • Prefer aggregated drills ([brand, total_revenue]) over row dumps at the top levels of a hierarchy; only the last level should be row-grain.
  • Cap the field count. A 60-column drill on a billion-row fact table scans far more than the dashboard tile did.
  • Drills ignore aggregate awareness — a drill on an aggregate-aware measure falls back to the base table by design, because the rollup has no detail rows. Expect the cost and make sure the base table is partitioned on the field your drills filter by.
  • Drill results honour caching policy, so a hot drill path with a sensible datagroup is nearly free after the first click.

A review checklist

Before you call a model finished, walk this list:

  1. Every headline measure has a drill_fields that a non-analyst can read.
  2. Every dimension in a natural hierarchy drills to the level beneath it and carries a measure.
  3. Drill field lists are sets, referenced by name, not pasted inline.
  4. No drill set exposes PII outside a model gated by an access grant.
  5. Links that point at dashboards url_encode every interpolated value.
  6. You have clicked each drill once as a non-admin user.

Drill design is one of the cheapest ways to raise trust in a Looker deployment: the number stops being a claim and becomes something a user can open. It is also one of the first things we fix during a Looker health check.

Need help auditing your LookML for drill, permission, and performance gaps? Get in touch with Vistelio — our senior Looker developers do this work every week.