Docs / Build Workflow

Models

Models are your semantic API

Put business logic in Malloy models, not in visualization YAML. If a metric is useful in more than one chart, define it once in the model layer and reuse it everywhere. Changing the definition of "revenue" should mean editing one line in one file, not hunting through visualization configs.

Write Malloy idiomatically — not only valid Malloy

Looky requires a strict file shape (pragma at the top of the file, parameterized sources, signature parameters — covered in Minimal model below). Beyond compliance, maintainable workspaces use Malloy for what it is designed for: a clear semantic layer on top of your data.

Prefer the semantic layer before defaulting to raw SQL

Declare dimensions, measures, and joins on alias.table(...) or an imported base with extend. Put chart-ready analytics in named views or top-level queries. Reach for large alias.sql("""…""") bodies when legacy SQL bundles the only practical shape of the data, or when you need raw-SQL @param substitution (often on Postgres or MySQL — see Source adapter differences). When everything lives inside opaque SQL strings you lose reusability across views and make reviews harder.

Modularize: scope files by domain

Keep unrelated analytics in separate .malloy files. Share a stable foundation via import and extend once per topic — see the Reusing a base source pattern below. Avoid one endlessly growing file that mixes many domains; it is difficult to reuse, audit, or split later.

Stable names are the contract — for teammates and tooling

Visualizations bind to path/model.malloy::query_or_view_name; treat those names as APIs. Prefer output field labels that reflect business meaning. Align p_* parameter names across models on the same dashboard where they represent the same dimension so filters and cross-filters behave predictably. Structured, named semantics are easier for people reviewing diffs and for assistants or automation locating the authoritative definition.

Minimal model you can ship today

##! experimental.parameters

source: sales() is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend {
  dimension: product is product_name
  dimension: order_date is created_at::date
  measure: sales_amount is sum(sale_price)
}

query: total_sales is sales() -> {
  aggregate: sales_amount
}

query: sales_by_product is sales() -> {
  group_by: product
  aggregate: sales_amount
  order_by: sales_amount desc
  limit: 10
}

Two things every model needs:

  • ##! experimental.parameters at the top of the file. Looky's engine compiles every model under that flag.
  • Empty () after the source name (source: sales()) and after every reference to it (sales() -> {...}). Even when the source takes no parameters, the parentheses are required — they declare the parameter list.

The source alias (ecommerce) must match an alias defined in runtime/sources.runtime.yml. The query names (total_sales, sales_by_product) become the reference handles used in visualization YAML.

Views: defining named views inside the source

For larger models, define named views inside the source using view: instead of top-level query: declarations. Views live inside the source and can reference its dimensions and measures directly.

##! experimental.parameters

source: ec_revenue() is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend {
  dimension: category is products.category
  dimension: order_month is created_at::month
  measure: revenue is sum(sale_price)
  measure: order_count is count(order_id)

  view: over_time is {
    group_by: order_month
    aggregate: revenue, order_count
    order_by: order_month asc
  }

  view: by_category is {
    group_by: category
    aggregate: revenue, order_count
    order_by: revenue desc
    limit: 12
  }
}

A visualization references a view exactly like a top-level query:

query: "models/ec_revenue.malloy::over_time"

Use views when all queries belong to the same semantic source. Use top-level queries when you need to reference multiple sources or run cross-source joins — and remember to invoke the source with () in the top-level form (query: x is ec_revenue() -> {...}).

Parameters: wiring dashboard filters to model queries

Dashboard filters control queries through parameters. Declare the parameter on the source signature (between the parentheses) and the dashboard filter passes its value through at render time.

##! experimental.parameters

source: ec_orders(
  p_cutoff_date::date is @2024-01-01
) is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend {

  measure: revenue is sum(sale_price) ? created_at <= p_cutoff_date

  view: kpi is {
    aggregate: revenue
  }
}

Each parameter takes a name, a type, and a default — p_cutoff_date::date is @2024-01-01. Common types: date, string, number, boolean. The default is what the engine uses when the dashboard does not provide a value (and what looky validate uses to dry-run the model).

The dashboard filter binds to the parameter by name:

# in the dashboard YAML
filters:
  - id: global_period
    type: cutoff_date
    granularity: year
    param: p_cutoff_date    # must match the parameter name in the model
    default: ""

When a user changes the filter, the parameter value is passed to every query in the dashboard that references it.

Strict @param contract for raw-SQL sources

If your source is built from raw SQL (connection.sql("""…""")) and the SQL uses @param placeholders, every @param must have a matching declaration on the source signature. There is no implicit fallback — undeclared placeholders fail validate with a clear error pointing at the missing declaration.

##! experimental.parameters

source: nv_asesor_kpi_sql(
  p_date_from::date is null,
  p_date_to::date   is null
) is natural_vitality_ventas.sql("""
  WITH date_range AS (
    SELECT
      COALESCE(CAST(@date_from AS DATE), DATE '2026-02-01') AS sd,
      COALESCE(CAST(@date_to   AS DATE), DATE '2026-02-28') AS ed
  )
  -- ...
""") extend {
  view: kpi_asesor_resumen is { select: * }
}

The placeholders @date_from and @date_to in the SQL match the declarations p_date_from and p_date_to on the source signature (the p_ prefix is conventional). The dashboard's filter passes the value at render time; validate uses the declared default.

Reusing a base source across models

The pattern is: define a parametric base source once and extend it in each domain model. This avoids duplicating join definitions, dimension declarations, and parameter declarations across every file.

# ec_orders_base.malloy — shared foundation
##! experimental.parameters

source: ec_orders_base() is ecommerce.table('...order_items') extend {
  join_one: products is ecommerce.table('...products') on product_id = products.id
  measure: revenue is sum(sale_price)
  measure: order_count is count(order_id)
}

# ec_revenue.malloy — domain model extends the base
##! experimental.parameters
import "ec_orders_base.malloy"

source: ec_revenue() is ec_orders_base() extend {
  view: by_category is {
    group_by: products.category
    aggregate: revenue
    limit: 12
  }
}

Keep the base model stable. Add new views in domain-specific files, not in the base. Both the base source and the extending source need their own () — and the extending source must invoke the base with () too (is ec_orders_base() extend {...}).

How a model's source alias is resolved

A Malloy model uses a connection alias when it calls alias.table(...) or alias.sql(...). Looky matches each alias against the workspace's runtime/sources.runtime.yml declarations.

  • If the model uses one alias, Looky picks it automatically.
  • If the model uses several aliases (directly or through imports), the run must say which one to use; otherwise it is rejected.

See Sources for the alias-declaration syntax per adapter.

Adapter notes for model authors

Looky bundles a specific, pinned Malloy version with the BigQuery, Postgres, and MySQL adapters. There are no Looky-specific Malloy extensions beyond what that Malloy version documents for those adapters.

The most common adapter difference in models is around date and timestamp parameters. On Postgres and MySQL, prefer the raw-SQL @param placeholder pattern (paired with a matching declaration on the source signature, as shown above) so Looky substitutes the value into the SQL string instead of binding it natively. See Source adapter differences for the full pattern.

Model quality checklist

  • ##! experimental.parameters at the top of every .malloy file.
  • Every source: declaration uses parentheses, even when empty (source: foo() is …).
  • Every @param placeholder in raw SQL has a matching declaration on the source signature.
  • Source alias exists in runtime/sources.runtime.yml (see Sources).
  • View and query names are stable — renaming them breaks visualization references.
  • Field names reflect business meaning, not chart formatting needs.
  • Parameters are declared with sensible defaults so queries work without a filter.
  • Each view or query can be reused by multiple visualizations.
  • Prefer declarative table + extend + views before relying on very large sql(...) sources, except where legacy shape or Postgres @param patterns require them.
  • Scope files to one domain or a deliberate shared base — avoid cramming unrelated queries into a single monolithic .malloy file.
looky validate
looky diff

Do not start visualization work until model validation is clean.