Skip to content

Migrating from Looker (LookML)

The LookML importer loads Looker LookML models into Saiku as Mondrian-4 schemas so you can run OLAP/MDX over them. It is a migration accelerator with a hard safety gate, not a lossless converter — every construct is classified into one of three verdicts:

  • CLEAN — ported to a Mondrian element.
  • DEGRADE — ported, but a capability was lost (and named precisely).
  • REFUSE — not ported, with an exact diagnostic of why.

How it works

The importer is a four-stage pipeline:

  1. Parse — read the .lkml files into a model (a vendored, hardened LookML parser).
  2. Classify — a static safety gate marks every explore and field CLEAN / DEGRADE / REFUSE, with no warehouse access.
  3. Transpile — emit a Mondrian-4 schema for the CLEAN/DEGRADE subset (as YAML, loadable directly by Saiku), plus a provenance map of which LookML field produced which schema element.
  4. Report — a coverage report (Markdown + JSON) bucketing every construct, with summary ratios.

Only constructs the gate accepts ever reach the transpiler, so a refused, silently-wrong measure can never leak into the emitted cube.

Running the importer

The importer is exposed as the mondrian.lookml.report.LookmlReportCli tool (the same way the Schema CLI wraps SchemaCli). Point it at a single .lkml file or a whole project directory — directories are scanned recursively for .lkml files.

Terminal window
lookml-report report <path> [-o report.md] [--json report.json] [--fail-on-refuse]
FlagEffect
(none)Prints the Markdown report to stdout.
-o <file>Writes the Markdown report to a file.
--json <file>Also writes the machine-readable JSON report.
--fail-on-refuseStill emits the report, then exits non-zero if anything was refused — a CI gate for “block the migration until the refuse list is empty.”

Exit codes: 0 success · 1 bad arguments · 2 the path is missing/unreadable, nothing was parseable, or --fail-on-refuse saw a refusal.

What the report contains

  • Summary metrics — CLEAN / DEGRADE / REFUSE counts and percentages at both explore and field granularity. This ratio is the headline migration-readiness number.
  • Per-construct buckets — each explore and field listed under Clean / Degrade / Refuse with a precise reason, the produced M4 element (for clean/degrade) or the lost capability, and a link to the Saiku feature that would lift a refusal.
  • Unparseable / skipped files — full transparency on anything not ingested.

Multi-file projects

Point the importer at a project directory and it resolves the whole project, not just one file:

  1. Every .lkml is discovered recursively and parsed independently (unparseable files and *.dashboard.lkml are listed, never fatal).
  2. The parseable top-level objects are merged, then a flatten pass resolves cross-file references into one model before classification:
    • include: — satisfied by the merge;
    • extends: — the base is copied, then the extending object’s own properties override;
    • refinements (+view / +explore / +model) — layered onto the base (scalars override; dimension / measure / join merge by name);
    • @{constant} — substituted from constant: blocks.

This fixes mis-classifications where a measure or field only becomes additive, Liquid, or row-secured after a refinement — on a real project this moved hundreds of fields from a literal-text guess to the correct verdict.

Importing from a live Looker instance (Explore JSON)

With a credentialed Looker instance you can skip raw .lkml parsing and import an explore’s already-resolved metadata:

Terminal window
lookml-report report --explore-json explore.json

Export the explore’s lookml_model_explore JSON from the Looker API. Because Looker has already applied all extends/refinements/constants/Liquid, this input skips the flatten pass and feeds straight into the same classifier → transpiler → report pipeline, producing the same coverage report as the equivalent .lkml. Trade-off: it requires API access and is per-explore, so it complements — rather than replaces — the offline, point-at-a-git-repo path.

What ports

Most of a clean star/snowflake LookML model converts directly. The importer maps:

LookMLMondrian-4Verdict
explore (single-base star/snowflake)<Cube> with a <MeasureGroup> and conformed dimensionsCLEAN
explore joining multiple fact bases (conformed)one <Cube> with a <MeasureGroup> per fact base over shared conformed dimensionsCLEAN
join: { relationship: many_to_one | one_to_one, type: left_outer }ForeignKeyLink (degenerate dims → FactLink)CLEAN
bridge two-hop: fact one_to_many (or many_to_many) → bridge view → many_to_one dimension view<BridgeLink> (full-count de-dup) + the dimension as a conformed dimensionCLEAN
measure: { type: sum | count | min | max | average | count_distinct }<Measure> with the matching aggregatorCLEAN
measure: { type: median | percentile }aggregator="median" / aggregator="percentile"DEGRADE — needs a PERCENTILE_CONT-capable backend
filtered measure (filters: equality, no Liquid)calculated memberCLEAN
dimension (+ value_format / value_format_name, label, description)attribute / level (+ formatString — Looker named formats like usd, percent_2, decimal_0 are translated to Mondrian masks; an unknown named format is kept verbatim with a DEGRADE note — plus caption, description)CLEAN
dimension: { type: tier } / dimension_group: { type: duration }native <Tier> / <Duration>CLEAN
parameter (bounded: typed, allowed_values)<QueryParameter>CLEAN
measure: { type: sum_distinct | average_distinct }, sql_distinct_key resolves to a column in the measure’s own view (incl. a non-primary-key)<Measure> with a measure-level distinct grain (distinctKeyColumn) — de-duped on that key before aggregating; collapses to a plain sum/avg when the key is the view’s primary keyCLEAN
bounded Liquid: {% parameter %}, {% condition %}, {{ _user_attributes['x'] }}<QueryParameter> / predicate-grant bindingDEGRADE
access_filter on a modelled dimension keyRole member grantCLEAN
access_filter on an arbitrary fact columnpredicate-grant Role + bound parameterDEGRADE
derived_tablea SQL-backed physical table (<Query>)DEGRADE — persistence policy dropped
drill_fieldsdrillthrough RETURN set, carried as a cube annotation (M4 has no <DrillThrough> schema element — it’s a runtime DRILLTHROUGH … RETURN statement)CLEAN
aggregate_table(not converted — Saiku regenerates aggregates)DEGRADE

A sum/average measure that fans out across a one_to_many join is ported (CLEAN) when the base view declares a primary key, because Saiku’s fan-out-safe symmetric aggregation de-duplicates on that grain. Without a declared key it is refused rather than risk double-counting.

Many-to-many joins (bridge dimensions). The importer recognises the canonical LookML bridge two-hop — a fact joined one_to_many (or many_to_many) to a bridge view, which in turn joins many_to_one to a dimension view — and maps it to a Mondrian <BridgeLink> instead of refusing the explore as non-star. The dimension reached through the bridge becomes a normal conformed dimension, and measures on the fact return the de-duplicated (full-count) total, not the fanned-out one. A bridge is emitted only when every hop reduces to a single-column key and the fact view declares a primary_key: yes grain; a compound/ambiguous join key, or a fact with no primary key, is left refused rather than producing a silently-wrong cube. Since LookML carries no allocation weight, bridges default to full-count de-duplication.

from:-aliased joins. In LookML a joined field is always referenced by the join name, while from: (or view_name:) only swaps the underlying physical view. The importer matches each join’s sql_on ${name.column} against the join name — resolving the dimension’s columns and table from the underlying view — so a join like join: current_subscription_state { from: logical_subscriptions; sql_on: ${fact.fk} = ${current_subscription_state.id} } ports CLEAN as a conformed dimension named for the join. Two joins that from: the same base view become two distinct conformed dimensions (e.g. account_csm and account_owner over one account table). What still degrades (DEGRADE_JOIN_SQL_ON_UNPARSEABLE): a sql_on that isn’t a single-column equality on each side — constant/metadata joins (${meta.col} = 'literal') and compound or expression joins (AND-chained multi-column keys, coalesce(...), ::date casts) are kept degraded rather than reduced to a single, silently-wrong key.

What is refused (and why)

RefusedWhyPath forward
Computed Liquid ({% if %} / loops / assign / computed {{ }} in SQL)Runtime-generated SQL is a correctness/security hole we will not importBounded {% parameter %} / {% condition %} / {{ _user_attributes['x'] }} now ports (DEGRADE)
Fan-out sum/average with no declarable grainWould double-count silentlyAdd a primary_key: yes dimension on the base view
Non-star topologies: full_outer / cross joins, or a many_to_many whose bridge two-hop can’t be recovered (compound/ambiguous join key, or the fact view has no primary_key)Break structurally / fan out uncontrollably, or there’s no single-column grain to de-dup onFor a many-to-many: give the fact view a primary_key: yes and single-column join keys so the importer can emit a bridge (the recoverable two-hop now ports automatically)
type: sum_distinct / average_distinct whose sql_distinct_key is a cross-view (${other_view.field}), foreign-key, or expression keyA measure-level distinct grain can only de-dup on a column in the measure’s own fact view; a cross-view key needs the bridge two-hopModel the join with a bridge (many-to-many) dimension
type: listNo multidimensional equivalent

Mondrian++ extensions widen coverage

Saiku’s semantic extensions exist partly to narrow the refuse list — each one turns a former refusal into a clean port:

  • Symmetric (fan-out-safe) aggregation, bridge (many-to-many) dimensions and measure-level distinct grain — see Advanced.
  • median / percentile aggregators, native tier / duration dimension types — see Dimensions and Cubes and measures.
  • Bounded query-context parameters and predicate-based row security — see Access control.

As more extensions land, the same LookML model classifies cleaner — re-run the report to see the ratio improve.

Real-world coverage

Validated against a corpus of public LookML projects (official Looker blocks plus production community models, ~900 .lkml files): ~99.8% of in-scope files parse, every project produces a whole-project report, and across ~20,000 fields in real models coverage is ~97.6% CLEAN, ~1.2% DEGRADE, ~1.2% REFUSE. The residual refusals are dominated by genuinely-computed Liquid (inherently dynamic — a “won’t port by design” rather than a bug).

Validating numerical equivalence

The coverage report tells you what ports; it does not tell you whether the converted cube returns the same numbers as Looker. A CLEAN port that totals wrong is the worst outcome, so a separate equivalence harness checks the converted cube against a live Looker instance — the migration analogue of the engine-side Calcite parity guard.

Given a query spec (an explore plus the dimension and measure fields), the harness runs the query both ways and compares the results:

  • Looker sidePOST /api/4.0/login then /api/4.0/queries/run/json returns the rows as the oracle. Point it at your instance with three settings (system properties or environment variables — never commit them): LOOKER_BASE_URL, LOOKER_CLIENT_ID, LOOKER_CLIENT_SECRET (a Looker API3 key). Without all three the client is inert and the harness stays fully offline.
  • Saiku side — the same spec is rewritten to MDX over the converted cube using the transpiler’s provenance map (measures on columns, dimension levels on rows). Fields the importer didn’t convert CLEAN are listed as skipped, never silently compared.
  • Comparison — rows are aligned by their dimension-key tuple and measures compared within a relative tolerance (default 1e-6). Divergences are categorised ROW_COUNT, DIMENSION_SET, or MEASURE_VALUE and name only the field and category — never the underlying values (no data in logs). A clean run reports a match with zero divergences.

The harness also validates row security: a Looker access_filter-restricted result is compared against the converted cube queried under the corresponding role grant, confirming the restricted numbers match.

Limitations (v1)

  • Cross-file resolution of include:, extends:/refinements, and @{} constants is handled by a flatten pass (see Multi-file projects); a reference whose base or constant is outside the discovered file set is reported as a diagnostic and left as-parsed rather than resolved. For a fully pre-resolved model, use the Explore-JSON front-end.
  • Conformed multi-base explores port to one cube with a <MeasureGroup> per fact base; v1 links each secondary fact group only to the conformed dimensions its own sql_on keys — base-view degenerate dimensions and cross-fact copy/no_link wiring are not yet synthesised.
  • *.dashboard.lkml (YAML-structured Looker dashboards) are skipped — they are not part of the cube model.

See YAML schemas for the format the importer emits, and the Schema CLI for converting or linting the result before deploying it to Saiku.