The Malloy version Looky runs
Looky ships a specific, pinned version of Malloy with the BigQuery, Postgres, and MySQL adapters at the same version. Any Malloy syntax beyond what that version supports is not understood; any feature added in later Malloy releases is not available until Looky upgrades.
There are no Looky-specific Malloy extensions. The dialect you write is what Malloy itself documents — no more, no less.
Supported adapters
- BigQuery — connects with a service-account JSON key.
- Postgres — connects with a libpq connection string (host / port / database, plus any TLS or pool flags) and a user/password secret.
- MySQL — connects with a
mysql://host:port/databaseconnection string and a user/password secret. Two caveats: connections are not encrypted (no TLS option yet), and MySQL has no real boolean type, so boolean columns read back as numbers — cast explicitly when you need a true/false filter.
No other adapters are bundled. See Sources for the per-adapter source YAML schema.
How models are loaded
Each workspace's content is rooted at workspaces/<billing>/<slug>/content/. Looky resolves .malloy files relative to that root.
import "..." statements inside a model are resolved against sibling .malloy files in the same directory. Use imports to share a base source declaration across multiple domain models — declare the source and its joins once, extend it per topic.
# models/orders_base.malloy — shared foundation
##! experimental.parameters
source: orders_base() is bigquery.table('...order_items') extend {
join_one: products on product_id = products.id
measure: revenue is sum(sale_price)
}
# models/revenue.malloy — domain model extends the base
##! experimental.parameters
import "orders_base.malloy"
source: revenue() is orders_base() extend {
view: by_category is {
group_by: products.category
aggregate: revenue
}
}
Every model needs the ##! experimental.parameters pragma at the top and parentheses on every source declaration (and on every reference to a source) — even when the source takes no parameters. The empty () declares the parameter list.
Source aliases inside models
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.
Named parameters
Filter and cross-filter values reach a query through named parameters declared on the source signature, between the parentheses. Each parameter takes a name, a type, and a default — p_country::string is "all".
There is one naming rule: parameters whose Malloy name starts with p_ have the prefix stripped for the external name. A model parameter declared as p_start_date is set by sending start_date from the dashboard filter.
##! experimental.parameters
source: orders(
p_country::string is "all",
p_start_date::date is @2024-01-01
) is bigquery.table('...') extend {
view: revenue is {
where:
(p_country = "all" or country = p_country)
and created_at::date >= p_start_date
aggregate:
revenue is sum(sale_price)
}
}
# filter / pill sends
params:
country: "MX"
start_date: "2024-12-01"
Parameters can be referenced two ways inside the source body:
- Malloy expressions — write
p_country,p_start_datedirectly inwhere:,aggregate:, etc. (the example above). - Raw-SQL
@paramplaceholders — when the source body is built from raw SQL (connection.sql("""…""")), use@paramplaceholders inside the SQL block. Every@parammust have a matching declaration on the source signature; otherwise validate fails with a clear error.
Date and timestamp parameters on Postgres and MySQL
Postgres and MySQL share a known limitation when Malloy binds a date or timestamp parameter natively in some query shapes. The reliable path on both is the raw-SQL @param pattern: build the source from connection.sql("""…"""), reference @param placeholders inside the SQL, and declare each placeholder on the source signature.
##! experimental.parameters
source: orders(
p_date_from::date is null,
p_date_to::date is null
) is postgres.sql("""
select *
from orders
where (@date_from::date is null or created_at::date >= @date_from::date)
and (@date_to::date is null or created_at::date <= @date_to::date)
""") extend {
view: revenue is {
aggregate: revenue is sum(sale_price)
}
}
Looky substitutes the @date_from / @date_to placeholders with literal values (or typed NULL when the dashboard hasn't supplied a value). On BigQuery and MySQL the same pattern works — the substitution is dialect-aware. See the source adapter comparison for the full divergence list.
Cache and parameters
Each cached entry is scoped to one query in one model with one specific parameter combination. A user filtering by "MX" gets a cached result for that specific combination; a user filtering by "AR" triggers a separate cache entry the first time, then hits cache on subsequent loads.
Editing the .malloy file invalidates every cached entry for queries inside it on the next request. See Cache for the cache sidecar shape.
Worked patterns
String parameter with an "all" sentinel
##! experimental.parameters
source: orders(
p_status::string is "all"
) is bigquery.table('...') extend {
view: detail is {
where: p_status = "all" or status = p_status
select: *
}
}
Date range parameter pair (Malloy-expression style)
##! experimental.parameters
source: orders(
p_date_from::date is @2024-01-01,
p_date_to::date is @2024-12-31
) is bigquery.table('...') extend {
view: revenue is {
where:
created_at::date >= p_date_from
and created_at::date <= p_date_to
aggregate: revenue is sum(sale_price)
}
}
Date range parameter pair (raw-SQL @param style)
##! experimental.parameters
source: orders(
p_date_from::date is null,
p_date_to::date is null
) is postgres.sql("""
select *
from orders
where (@date_from::date is null or created_at::date >= @date_from::date)
and (@date_to::date is null or created_at::date <= @date_to::date)
""") extend {
view: revenue is {
aggregate: revenue is sum(sale_price)
}
}
The @date_from / @date_to placeholders are substituted at run time with the values from the filter (or with typed NULL when the dashboard hasn't supplied a value).
Month string parameter
##! experimental.parameters
source: orders(
p_month::string is "2024-12"
) is bigquery.table('...') extend {
view: monthly is {
where: format_datetime('%Y-%m', created_at) = p_month
aggregate: revenue is sum(sale_price)
}
}
Common pitfalls
- Missing
##! experimental.parametersor missing()on the source. Validate fails withunsupported_model_shape. Add the pragma at the top of the file and parentheses on everysource:declaration (and every reference to it). - Raw-SQL
@paramwith no matching declaration on the source signature. Validate fails withunbound_paramnaming the missing parameter. Add it to the parentheses, e.g.p_date_from::date is null. - Parameter name mismatch between filter and model. The filter sends the external name; the model receives the prefixed (
p_) Malloy name. Audit both sides. - The query fails when the parameter is unset. Declare a default in the model so the query still runs. For "all" semantics, default to a sentinel value the where-clause treats as no-op.
- The model uses a Malloy feature the engine does not know. Looky pins one Malloy version. Newer features fail at compile time. Stick to features documented in that version.
- Two models on the same dashboard declare different parameter names for the same dimension. Cross-filter clicks on that dimension only narrow the model that uses the matching name. Standardise the parameter names across models on the same dashboard.
- The cache returns stale data. Cache is keyed by parameter combination; if the data changed but the parameter combination is the same, the cached entry still wins until its TTL expires (or the model file changes).