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.
Minimal model you can ship today
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
}
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: the pattern used in ecommerce-showcase
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.
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.
Parameters: wiring dashboard filters to model queries
Dashboard filters control queries through parameters. Declare the parameter in the model and the dashboard filter passes its value through at render time.
source: ec_orders is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend {
# declare a parameter with type and default
declare:
p_cutoff_date is @2024-01-01::date
measure: revenue is sum(sale_price) ? created_at <= p_cutoff_date
view: kpi is {
aggregate: revenue
}
}
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.
Reusing a base source across models
The ecommerce-showcase defines a parametric base source once and extends it in each domain model. This avoids duplicating join definitions, dimension declarations, and parameter declarations across every file.
# ec_orders_base.malloy — shared foundation
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
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.
Model quality checklist
- Source alias exists in
runtime/sources.runtime.yml. - 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.
looky validate
looky diff
Do not start visualization work until model validation is clean.