Support
Log In

Migrate from Looker

Convert your LookML views, explores, and models into governed Malloy data models

Your LookML is years of encoded business logic — dimension definitions, measure formulas, join relationships, and the curation decisions behind them. Credible reads it as prior art and rebuilds the analytical domain as governed Malloy: the same metrics — validated to the row wherever a connection allows — plus the context an AI agent needs to answer the questions your explores couldn't.

Still weighing the move? Credible vs. Looker sets the two side by side.

LookML's UI patterns, Liquid templating, and performance-only constructs are identified and deliberately left behind.

What Credible Reads

The adapter is malloy-lookml-review, MIT-licensed in Malloy Publisher. Each phase is a reference file you can read before you run it — field proposals, derived-table classification, visibility mapping, and the coverage report — so the rules applied to your project are inspectable rather than implied.

The agent inventories your LookML project — manifest, model, view, and explore files — resolving manifest constants as it goes. Give it the .lkml files and it has what it needs; it can also work from dashboards and other unstructured context when that's all you have. How far validation goes depends on what else it can reach:

  • LookML + live data — with a warehouse connection (and, optionally, the Looker API), LookML supplies the business context and the data validates each proposal against live results.
  • LookML only — with no connection, LookML is the sole source of context and each proposal is flagged unvalidated until data confirms it.

Where API access is available, the agent can reach it through looker-mcp-server, a community project under Apache 2.0 that is not affiliated with Looker, rather than bespoke API code. It takes Looker API credentials, so review it as you would any tool you hand a service account. Its explore and query groups browse models and run the original explore for parity checks, and its audit group reads query history, content usage, and PDT build logs out of System Activity for the usage trim. Start it with only the groups you need — looker-mcp-server --groups explore,query,audit — since the modeling, git, and admin tool groups are opt-in for a reason.

What Comes Across

Dimensions and measures carry over as they are. The bigger pieces move like this:

In LookerIn Credible
Views & exploresMalloy sources, joins folded in; import/export and the explores manifest curate what's exposed
Dimensions & measures (filtered, ratio, time)The everyday building blocks, carried over
Persistent (PDT) & native (NDT) derived tables#@ persist on the source for performance-only PDTs — one annotation replaces derived_table, datagroup_trigger, and the datagroup — and query-as-source for real transformations, minus the build schedules and cascading rebuilds
access_filterA row-level #(authorize) — the same annotation, reading a column of the source, so each caller sees the rows their grants admit. Reported as the source's authorize in introspection, and it survives derivation
access_grant, required_access_grantsA whole-source #(authorize) — the expression names only secure givens and literals, so it admits every row or none
Model & explore permissionsResource permissions at the package and environment level, with #(authorize) for the source-level question
description: and labels#(doc) / #(index) tags, compressed into the engine's concept index so an agent can find the right field and use it correctly
Dashboards & LooksRebuilt in the shape that fits: a dashboard (a tagged Malloy file with filter controls, a grid, and drill-through), a notebook where the numbers need prose, or an HTML data app where the design matters — none capped at Looker's tile set
Liquid SQL templatingReal typed Malloy expressions — no SQL string-templating to write or debug
drill_fieldsSkipped as Looker UI detail, and reported as skipped:looker-ui. Drilling is a separate, deliberate step: a # drill tag on a model dimension makes every result grouped by it clickable, landing on the destination view with the clicked value filtered in — the same in a dashboard and a notebook, because the tag lives in the model rather than on a tile
html:, viz stylingPresentation, so none of it carries over. Where one hides a business rule — an html: block that flags margin under 5% — the rule becomes a real field and the styling is left behind

The Migration Flow

Read

Inventory every .lkml file, categorize it, and extract source and join candidates with prior-art notes. The explore/view split collapses into a single Malloy source: joins move from the explore into the source, and relationship: many_to_one becomes join_one.

Translate

Extract field-level proposals from each view — dimensions and measures with a lookml provenance — and convert derived tables and struct/UNNEST joins. Apply the keep / skip / flag triage: keep aggregation formulas, join cardinality, and CASE logic; skip drill_fields, html:/Liquid, and PDT optimization keys; flag 50-line SQL dimensions and synthetic primary keys.

Enrich

Rewrite each LookML description: into a #(doc) tag that tells an agent what the field means and how to use it, #(index) the categorical dimensions, and map LookML visibility (hidden, fields exclusions, required_access_grants) to Malloy access modifiers and access control.

Validate

Confirm numeric parity and produce a coverage report — what was modeled, renamed, rearchitected, deferred, or skipped, and why.

What Credible Handles

  • Liquid and HTML{% … %} templating and html: conditional formatting are stripped; their intent is noted, and re-created as a renderer annotation only if it belongs in the model.
  • Persistent derived tables — classified as native derived table, performance-only, or transformation. Perf-only PDTs become a #@ persist annotation on the base source; real transformations become query-based sources.
  • Refinements (+view) — consolidated into one definition rather than layered, so there's a single source of truth per field.
  • Synthetic keys — a primary_key built from concat() or generate_uuid() is flagged so you can confirm the real grain instead of baking in a workaround.
  • Access control, by layer — Looker's three mechanisms all look like security, and conflating them is the failure that stays invisible until it matters. Two of them land on the same annotation: both access_filter and required_access_grants become #(authorize), and the expression decides which — name a column of the source for row-level scoping, name only givens for a whole-source gate. The third, sql_always_where, sits in a security-shaped slot but often carries a data-quality filter, so it is documented as context rather than baked in. Read it before you move on: where it genuinely restricts access, that rule has to be re-expressed deliberately as #(authorize) or a where:, because documenting it does not enforce it. Every required_access_grants is flagged for a person the same way rather than converted.
  • Masking is not gating — a Malloy model carries no per-viewer context of its own, so a pick that coarsens a value keys on row data or a parameter. "Everyone sees a band" is masking; #(authorize) decides whether a caller sees the field at all. They are independent layers.
  • Visibility, by reason rather than by keywordhidden: yes is cosmetic (the field is still queryable by URL and API) while a fields exclusion is structural (it never enters the pool). They map by reason: a hidden intermediate calculation keeps a # hidden tag and stays reachable, a hidden join key stays plain and public, a field hidden as clutter and genuinely unused becomes internal:, an excluded field becomes internal: outright, and required_access_grants becomes an access control decision. Mapping hidden: yes straight to internal: over a few hundred fields is the quiet way to break a model an agent has to use.
  • Entity-attribute-value joins — where LookML widened an EAV table by joining it once per attribute, the agent replaces the N joins with one grouped scan of filtered aggregates: one aggregate: line per attribute. Adding an attribute later is one more line, not one more join.

Proving Parity

Two channels, used together:

  1. Looker API — run the original explore through the API and compare. This requires the service account to satisfy the explore's required_access_grants, or restricted explores return 404 — indistinguishable at a glance from "explore not found," and not self-fixable without administer/sudo. The agent preflights the user attributes those grants key on before building anything on this path, rather than discovering it through 404s.
  2. Malloy against the same warehouse — run the model against the warehouse the LookML reads and diff it against the equivalent SQL run directly there. Both sides hit the same data, so a difference is a difference in logic. This is the channel that validates the numbers in practice, with or without API access.

Before & After

A LookML view and explore:

view: orders {
  sql_table_name: sales.orders ;;

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

  dimension: status {
    label: "Order Status"
    description: "Current fulfillment status of the order"
    type: string
    sql: ${TABLE}.order_status ;;
  }

  dimension: order_size {
    type: string
    sql: CASE
           WHEN ${TABLE}.amount >= 100 THEN 'large'
           WHEN ${TABLE}.amount >= 20  THEN 'medium'
           ELSE 'small'
         END ;;
  }

  dimension_group: created {
    type: time
    timeframes: [date, week, month, year]
    sql: ${TABLE}.created_at ;;
  }

  measure: order_count {
    type: count
    drill_fields: [order_id, status, created_date]   # dropped — UI only
  }

  measure: total_revenue {
    label: "Total Revenue"
    description: "Total revenue in USD"
    type: sum
    sql: ${TABLE}.amount ;;
    value_format_name: usd
  }

  measure: cancelled_orders {
    type: count
    filters: [status: "cancelled"]
  }

  measure: cancellation_rate {
    type: number
    sql: 1.0 * ${cancelled_orders} / NULLIF(${order_count}, 0) * 100 ;;
    value_format_name: percent_1
    html: {% if value > 10 %}<span style="color:red">{{ rendered_value }}</span>{% endif %} ;;
  }
}

explore: orders {
  join: customers {
    type: left_outer
    sql_on: ${orders.customer_id} = ${customers.customer_id} ;;
    relationship: many_to_one
  }
}

The same domain in Malloy — one source, joins folded in, Liquid and drill fields dropped:

source: orders is conn.table('sales.orders') extend {
  primary_key: order_id
  join_one: customers is conn.table('sales.customers') on customer_id

  dimension:
    #(doc) Current fulfillment status of the order
    #(index)
    status is order_status

    #(doc) Order size bucket derived from amount
    order_size is
      pick 'large' when amount >= 100
      pick 'medium' when amount >= 20
      else 'small'

    #(doc) Date the order was placed
    created_date is created_at::date

  measure:
    #(doc) Number of orders
    order_count is count()

    #(doc) Total revenue in USD
    # currency
    total_revenue is sum(amount)

    #(doc) Orders that were cancelled
    cancelled_orders is count() { where: status = 'cancelled' }

    #(doc) Percentage of orders that were cancelled
    # percent
    cancellation_rate is cancelled_orders / order_count * 100

  view:
    #(doc) Monthly revenue trend with order counts
    monthly_revenue is {
      group_by: created_date.month
      aggregate: total_revenue, order_count
    }
}

The type: time dimension group becomes a single date dimension you truncate with .month/.year in a view — no enumerated timeframe list. drill_fields, the Liquid html: block, and value_format_name have no field-level model equivalent: drilling is implicit in Malloy, and formatting moves to # currency/# percent render tags.

More than a reformat. Off LookML, the model is AI-discoverable through the AI Analytics Engine, composes into questions your explores couldn't answer, and is open code you own rather than logic locked in Looker. See what you gain →

Next Steps

On this page