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:
- Parse — read the
.lkmlfiles into a model (a vendored, hardened LookML parser). - Classify — a static safety gate marks every explore and field CLEAN / DEGRADE / REFUSE, with no warehouse access.
- 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.
- 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.
lookml-report report <path> [-o report.md] [--json report.json] [--fail-on-refuse]| Flag | Effect |
|---|---|
| (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-refuse | Still 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:
- Every
.lkmlis discovered recursively and parsed independently (unparseable files and*.dashboard.lkmlare listed, never fatal). - 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/joinmerge by name); @{constant}— substituted fromconstant: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:
lookml-report report --explore-json explore.jsonExport 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:
| LookML | Mondrian-4 | Verdict |
|---|---|---|
explore (single-base star/snowflake) | <Cube> with a <MeasureGroup> and conformed dimensions | CLEAN |
explore joining multiple fact bases (conformed) | one <Cube> with a <MeasureGroup> per fact base over shared conformed dimensions | CLEAN |
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 dimension | CLEAN |
measure: { type: sum | count | min | max | average | count_distinct } | <Measure> with the matching aggregator | CLEAN |
measure: { type: median | percentile } | aggregator="median" / aggregator="percentile" | DEGRADE — needs a PERCENTILE_CONT-capable backend |
filtered measure (filters: equality, no Liquid) | calculated member | CLEAN |
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 key | CLEAN |
bounded Liquid: {% parameter %}, {% condition %}, {{ _user_attributes['x'] }} | <QueryParameter> / predicate-grant binding | DEGRADE |
access_filter on a modelled dimension key | Role member grant | CLEAN |
access_filter on an arbitrary fact column | predicate-grant Role + bound parameter | DEGRADE |
derived_table | a SQL-backed physical table (<Query>) | DEGRADE — persistence policy dropped |
drill_fields | drillthrough 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)
| Refused | Why | Path forward |
|---|---|---|
Computed Liquid ({% if %} / loops / assign / computed {{ }} in SQL) | Runtime-generated SQL is a correctness/security hole we will not import | Bounded {% parameter %} / {% condition %} / {{ _user_attributes['x'] }} now ports (DEGRADE) |
Fan-out sum/average with no declarable grain | Would double-count silently | Add 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 on | For 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 key | A 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-hop | Model the join with a bridge (many-to-many) dimension |
type: list | No 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 side —
POST /api/4.0/loginthen/api/4.0/queries/run/jsonreturns 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 categorisedROW_COUNT,DIMENSION_SET, orMEASURE_VALUEand 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 ownsql_onkeys — base-view degenerate dimensions and cross-factcopy/no_linkwiring 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.