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.parametersat 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.parametersat the top of every.malloyfile.- Every
source:declaration uses parentheses, even when empty (source: foo() is …). - Every
@paramplaceholder 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 largesql(...)sources, except where legacy shape or Postgres@parampatterns require them. - Scope files to one domain or a deliberate shared base — avoid cramming unrelated queries into a single monolithic
.malloyfile.
looky validate
looky diff
Do not start visualization work until model validation is clean.