| name | malloy-notebooks |
| description | Create Malloy notebooks (.malloynb) for interactive dashboards and data stories. Use when user asks to "create a notebook", "make a dashboard notebook", "write a malloynb", "data story", or needs to build reports/visualizations. |
Malloy Notebooks
Files: .malloynb
Scope: when to use this skill vs skill:malloy-analysis-report
This skill is for curated .malloynb notebooks. Interactive filter widgets render automatically from #(filter) annotations on the model's source. The notebook itself never declares filters.
For ad-hoc reports generated by an analysis agent, combining queries into a quick narrative artifact, use skill:malloy-analysis-report instead. Ad-hoc reports also inherit whatever #(filter) annotations are on the source.
Important Limitations
Compile errors in .malloynb files are NOT shown in the IDE linter. You will only see errors when cells are executed. Be extra careful with syntax and test queries in the model first if unsure.
Multiple Models & Cross-Model Joins
A published .malloynb runs each run: cell against the whole notebook, which compiles with all its imports in scope. So a notebook can import several .malloy models, and a single cell can reference, and join, sources from different imported files. Import each model you need in a setup cell at the top; no "re-export" wrapper model is required.
>>>malloy
import "flights.malloy"
import "carriers.malloy"
>>>malloy
// joins `flights` (from flights.malloy) to `carriers` (from carriers.malloy)
run: flights extend {
join_one: carriers on carrier = carriers.code
} -> {
group_by: carriers.nickname
aggregate: flight_count
}
Two things still hold:
- Imports are notebook-wide, declare them at the top, never inside a
run: cell. A cell's query runs as a restricted query against the already-compiled notebook, so its imports must already be in place. You can't import a model inside a query cell to scope it there; an in-query import is rejected (file imports are not permitted in a restricted query).
- Compile errors surface only at publish/render, not in the IDE linter (see above). Verify a multi-model notebook by publishing to a scratch environment and loading it, rather than relying only on single-model query checks.
No Data-Specific Insights in Markdown
Notebooks must NOT contain findings about current data values. Data refreshes will make these stale. Use markdown for framing questions and structural narrative, not for stating results.
>>>markdown
## WRONG: will become stale when data refreshes
Revenue spiked 23% in March. Electronics drove 60% of the spike.
>>>markdown
## RIGHT: frames the question, lets the query answer it
How is revenue trending? Which categories are driving changes?
Cell Syntax
Cells delimited by >>>markdown or >>>malloy:
>>>markdown
# Sales Analysis
Overview of sales trends.
>>>malloy
import "sales_model.malloy"
>>>markdown
## Summary
>>>malloy
run: orders -> summary
>>>markdown
## Monthly Trend
>>>malloy
# line_chart
run: orders -> by_month
NEVER create >>>malloysql cells. Only use >>>malloy and >>>markdown.
Interactive Filters
Interactive filters are common and important. Add them to most notebooks. Filters are declared once, on the model's source, using #(filter) annotations. The notebook itself does not declare or list filters. Publisher reads the source's filter metadata and renders the widgets above the notebook automatically, then injects where: clauses server-side on every cell.
Filter declarations live in the .malloy model file, above the source, using key=value parameters. The full reference (syntax, filter types, required / implicit flags) lives with the #(filter) guidance in the malloy-model skill's Parameterizable Filters section. Short example:
#(filter) name=Manufacturer dimension=Manufacturer type=in
#(filter) name=OrderStatus dimension=order_status type=in
#(filter) name=Recall_After dimension="Report Received Date" type=greater_than
#(filter) name=Recall_Before dimension="Report Received Date" type=less_than
source: recalls is duckdb.table('data/auto_recalls.csv') extend {
measure:
recall_count is count()
}
#(filter) lines go above the source: declaration and reference dimensions by name (use dimension=). name defaults to the dimension name and only needs to be set when two filters target the same dimension (e.g. a date range). type is the comparator: equal, in, like, greater_than, less_than.
Deprecated. #(filter) is server-side where: injection and is deprecated in favor of native Malloy given:. New notebooks should declare runtime parameters as givens (below) rather than adding new #(filter) annotations; see docs/givens.md for the migration recipes.
Runtime Parameters (given:)
A notebook's parameter surface comes from the given: declarations on the models it imports, not from anything declared in the .malloynb itself. When an imported model declares givens, Publisher's notebook UI automatically renders a Parameters panel above the notebook: declared givens become parameter inputs, the values a user sets are forwarded to Malloy's runtime, and every cell re-executes against the model with those values applied.
The widget for each parameter follows the given's declared Malloy type (string, string[], number, boolean, date/timestamp, filter<T>); see docs/givens.md for the full type table. A #(description="...") annotation on the given renders as helper text under its input.
##! experimental.givens
#(description="Two-letter IATA carrier code to spotlight")
given: carrier :: string is 'WN'
source: spotlight_carrier is duckdb.table('data/carriers.parquet') extend {
view: by_name is {
where: code = $carrier
select: code, name, nickname
limit: 1
}
}
Declare givens at the top of the model file, before the source that uses them, not in the notebook. malloy-model § Access Control covers the syntax and the #(authorize) gating story built on top of givens.
Notebook Style: Iterative Analysis (Default)
Build a story. Each query should reveal something that motivates the next query. Start broad, then drill into what's interesting. Frame questions in markdown, let queries answer them.
Pattern: Question, Query, Next Question, Drill
>>>markdown
## How is revenue trending?
>>>malloy
# line_chart
run: orders -> { group_by: order_month; aggregate: revenue }
>>>markdown
## What's driving the biggest changes?
>>>malloy
# bar_chart
run: orders -> { group_by: category; aggregate: revenue; order_by: revenue desc; limit: 10 }
>>>markdown
## How does the top category break down?
>>>malloy
run: orders -> { aggregate: order_count, avg_order_value; where: category = 'Electronics' }
When to use other styles:
- Dashboard with filters: User asks for "filterable dashboard". Show key KPIs and breakdowns with interactive filters.
- EDA/summary: User asks for "overview". Run views like
summary, by_month, by_category in sequence.
View Refinement
To add limit, where, or other options to an existing view, use +:
run: source -> my_view + { limit: 15 }
run: source -> my_view + { where: status = 'active', limit: 10 }
Template
>>>markdown
# [Title]
[Framing question: what are we trying to understand?]
>>>malloy
import "model.malloy"
>>>markdown
## [First question: start broad]
>>>malloy
run: main_source -> [broad query]
>>>markdown
## [Next question: motivated by what the first query reveals]
>>>malloy
run: main_source -> [drill into finding]
>>>markdown
## [Deeper question]
>>>malloy
run: main_source -> [further breakdown]
Common Mistakes
| Mistake | Fix |
|---|
view_name { limit: 10 } | Use +: view_name + { limit: 10 } |
# currency on non-money | Only use # currency for monetary values |
#(filter) {"type": "Star"} (JSON-blob form, on a dimension) | Unsupported legacy syntax. #(filter) goes above the source with key=value parameters (name=, dimension=, type=). See the malloy-model skill's Parameterizable Filters section. |
##(filters) [...] annotation in the notebook | Unsupported. The notebook does not declare or list filters. Publisher renders widgets automatically from the source's #(filter) annotations. |
| Creating MalloySQL cells | NEVER use >>>malloysql, only >>>malloy and >>>markdown |
| Data-specific insights in markdown | Don't write "Revenue grew 23%." Frame questions instead. Data refreshes will make findings stale. |
import inside a run: cell (to scope a model to that cell) | Imports are notebook-wide, put them in a setup cell at the top. An in-query import is rejected: file imports are not permitted in a restricted query (see Multiple Models & Cross-Model Joins). |
| Cross-model join inside a single ad-hoc report cell | A skill:malloy-analysis-report cell renders against a single model, so a cross-model join there won't render. Build a published .malloynb (which runs against the whole notebook) for a cross-model join. |
Best Practices
- Start with markdown framing the question
- Import each model the notebook needs in a setup cell at the top, cells can reference and join sources across all imports (see Multiple Models & Cross-Model Joins); verify a multi-model notebook by publishing to a scratch environment
- Each query should follow from what the previous one could reveal
- One query per cell
- Charts render only the FIRST aggregate
- Add interactive filters to most notebooks by declaring
#(filter) on the model's source (see Interactive Filters), or prefer given: runtime parameters for new models (see Runtime Parameters)
- Never include data-specific findings in markdown. Frame questions, let queries answer
Done
Step complete. Output: .malloynb notebook file.