Skill v1.0.3
currentAutomated scan100/100+1 new, ~3 modified
name: malloy-charts description: Read before choosing a chart or adding a chart annotation. Chart types, tag syntax and scale rules, KPI cards, dashboards, sparklines, and the renderer mistakes that fail silently. metadata: version: "1.0.3"
<!-- Copyright (c) Credible Data Inc. SPDX-License-Identifier: MIT -->
Chart Selection for Malloy
Malloy uses Vega-Lite under the hood.#tags control visualization. Callsearch_malloy_docswith topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview).
This file is about the Malloy renderer's `#` tags. It applies when the view itself is rendered - a tile or cell in a report (either layout), an explore result. It does not apply to an app that draws its own charts with a vendored chart library: there the tag vocabulary is irrelevant and the full form vocabulary is available, so the approximations below (a funnel as a bar chart, a treemap as a nested table) are the wrong advice. Design to whichever vocabulary the surface actually uses.
Tool names are written bare here -get_context,execute_query,search_malloy_docs. The exact prefixed name depends on the host surface; match each against the tools you actually have.
Decision Tree: Which Chart?
| Data Shape | Default Choice | |
|---|---|---|
| Aggregates only (no group_by) | # big_value | |
| 1 time column + 1 measure | # line_chart | |
| 1 category + 1 measure | # bar_chart | |
| 2 numeric columns | # scatter_chart | |
| Geographic (US states) + 1 measure | # shape_map | |
| Route data (lat/lon pairs) | # segment_map | |
| Multiple perspectives | # dashboard with nest: | |
| Nested query to pivot | # pivot | |
| Filtered aggregates side-by-side | # flatten | |
| Detailed rows | Default table (no annotation) |
| Goal | Renderer | |
|---|---|---|
| Compare categories | # bar_chart (sort by value, limit ~15) | |
| Show composition | # bar_chart.stack | |
| Trend over time | # line_chart | |
| Highlight KPIs | # big_value with # label | |
| Correlation | # scatter_chart | |
| Compare dimensions | # dashboard (nest chart views) | |
| Before/after | # transpose or # pivot | |
| Multiple metrics per category | Default table, # flatten, or y=['a','b'] |
Constraints:
- ONE aggregate per chart view (charts render only the first; use
y=['a','b']for multi-measure) - No fixed scale on measure definitions: use
# currencynot# currency=usd0m. The same measure renders at many granularities, andusd0mturns $500 into$0.0M. Add a scale only in a view, after confirming the value range with a query. - One tag per line, each directly above the field it styles. Two
#tags on one line do not work. - Alias joined fields in
group_bybeforeorder_by - Define measures in source, not in views
Chart Types
# bar_chart
Data shape: group_by = x-axis, aggregate = y-axis, optional 2nd group_by = series.
# bar_chartview: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }# bar_chart.stackview: by_region is { group_by: category, region, aggregate: revenue }# bar_chart { y=['revenue','cost'] }view: rev_vs_cost is { group_by: category, aggregate: revenue, cost }
Key properties: .stack, .size (spark/xs/sm/md/lg/xl/2xl), .x, .x.limit, .y (supports y=['a','b']), .series, .series.limit (default 20), .title, .subtitle, .x.independent, .y.independent
Field role tags: # x, # y, # series on individual fields to assign roles explicitly.
# line_chart
Data shape: group_by (temporal/numeric) = x-axis, aggregate = y-axis, optional 2nd group_by = series.
# line_chartview: trend is { group_by: order_month, aggregate: revenue, order_by: order_month }# line_chart { size=spark }view: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month }
Key properties: .zero_baseline, .interpolate (e.g., step), .size, .y (supports y=['a','b']), .series.limit (default 12), .title, .subtitle
# scatter_chart
Data shape: Fields by position: x, y, color, size (bubble), shape.
# scatter_chartview: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }
# shape_map
Choropleth. US states only. Fields: state name, value.
# shape_mapview: by_state is { group_by: state, aggregate: revenue }
# segment_map
Route map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color.
Layout Types
# big_value
KPI cards. Aggregates only, no group_by.
# big_valueview: summary is {aggregate:# label="Revenue"# currencyrevenue# label="Orders"# number=autoorder_count}
Properties: .size, .sparkline=<nested_view_name>, .comparison_field, .comparison_label, .down_is_good
Put # label on every measure. Without it the card shows the raw field name, which is often unclear.
# dashboard
Card-based multi-tile layout. Apply to a view whose body is a nested query; the view's own fields lay out automatically:
group_bydimensions -> a row header (repeats once per row; omit for a single block)aggregatemeasures -> KPI cards, one per measure- each
nest:-> a tile, rendered by the tag above it (# tabledefault, or# bar_chart/# line_chart/# big_value)
Two modes. Flex (default): tiles flow and wrap; # break forces a new row. Columns: # dashboard { columns=N } lays tiles into N equal columns, # colspan=n widens a tile (the old # span is gone), # break starts a new row, overflow wraps.
// Flex: measures become KPI cards, the nest becomes a tile# dashboardview: overview is {group_by: category# currencyaggregate:avg_retail is retail_price.avg()sum_retail is retail_price.sum()nest:# bar_chartby_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }}// Columns: # colspan widens tiles, # break ends a row# dashboard { columns=12 }view: layout is {group_by: category# currencyaggregate:# colspan=4avg_retail is retail_price.avg()# colspan=4sum_retail is retail_price.sum()# colspan=4max_retail is retail_price.max()nest:# break# colspan=6# bar_chart# subtitle="Top brands"by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 }# colspan=6by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 }}
Tags: # dashboard { columns=N } (columns mode), { gap=PX } (tile spacing, default 16; never a mode), { table.max_height=PX|none } (cap table tiles). On a measure or nest: # colspan=N (columns mode only), # break (both modes), # subtitle="..." (tile), # borderless (drop card chrome), # label="..." (card title).
For rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a # big_value view instead of relying on the dashboard's own measures:
# dashboardview: kpis is {group_by: categorynest:# big_valuerevenue_card is {aggregate:# label="Revenue"# currency# big_value { sparkline=trend }total_revenue is retail_price.sum()# line_chart { size=spark y.independent=true }# hiddennest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 }}}
Rules: # dashboard needs a nested-query view (no effect on a scalar). # colspan works only in columns mode and is ignored (warns) in flex. columns is any positive integer; a # colspan over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with # theme.* (see Theming below).
# pivot
Pivot nested results into columns. Max 30 pivot columns.
view: sales is {group_by: product, aggregate: totalnest: # pivotby_quarter is { group_by: quarter, aggregate: revenue }}
# transpose
Swap rows/columns. Good for period comparisons.
# transposeview: comparison is {aggregate:# label="This Month"current_revenue# label="Last Month"prior_revenue}
# list / # list_detail
List renders as comma-separated values. List_detail shows value (detail) pairs.
# flatten
Collapse nested record into parent table as columns. Use for side-by-side filtered aggregates:
view: segments is {group_by: product, aggregate: total_revenuenest: # flattenenterprise is { where: segment = 'Enterprise', aggregate: # label="Enterprise" revenue }nest: # flattensmb is { where: segment = 'SMB', aggregate: # label="SMB" revenue }}
# table
Default (implicit). Use explicitly for .size=fill property.
Field Formatting Tags
| Tag | Use For | Shorthand | |
|---|---|---|---|
# number | Numeric formatting | =auto (K/M/B), =id (no commas), =1k, =1m | |
# percent | Percentages | (none needed) | |
# currency | Money | =usd2m (USD, 2 decimals, millions); scale only in views | |
# duration | Time durations | =seconds, =minutes, =hours, =days | |
# data_volume | Storage sizes | =bytes, =kb, =mb, =gb | |
# link | Hyperlinks | .url_template="https://example.com/$$" | |
# image | Inline images | .height=40px, .width=100px |
# image and # link are fine in a model file. A document held as text (one with a model-level ## artifact tag, compiled at scope append) is refused if it writes either; define the field in the model file and check that edit at scope file.
Currency codes: usd ($), eur, gbp. Scale: K/M/B/T/Q or auto. Number suffix styles: word ("42.5 million"), letter ("42.5M"), scientific.
Utility Tags
| Tag | Purpose | |
|---|---|---|
# hidden | Hide from output (still usable for sorting/references) | |
# label="..." | Override display name | |
# description="..." | Tooltip text | |
# tooltip | Include nested view in chart tooltip | |
# break | Force new dashboard row | |
# column { width=sm } | Table column width |
Model-Level Defaults
## viz.line_chart.defaults.y.independent=true## viz.bar_chart.defaults.stack
Theming
Publisher styles charts and tables from one structured theme. The instance sets it (in publisher.config.json's theme block or the Settings, then Theme editor); a model overrides it per result with # theme.* annotations, or model-wide with ## theme.*. Per-chart annotations use the same nested palette.* / font.* vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms:
| Annotation | Controls | Modes | |
|---|---|---|---|
# theme.palette.series | Categorical series colors (array) | shared | |
# theme.palette.background.{light,dark} | Chart canvas + table background | per-mode | |
# theme.palette.tableHeader.{light,dark} | Table header text color | per-mode | |
# theme.palette.tableHeaderBackground.{light,dark} | Table header row background | per-mode | |
# theme.palette.tableBody.{light,dark} | Table body text color | per-mode | |
# theme.palette.tile.{light,dark} | Dashboard tile background | per-mode | |
# theme.palette.tileTitle.{light,dark} | Dashboard tile title color | per-mode | |
# theme.palette.mapColor.{light,dark} | Choropleth gradient (# shape_map / # segment_map) | per-mode | |
# theme.palette.border.{light,dark} | Table gridlines and row rules | per-mode | |
# theme.palette.cardBorder.{light,dark} | Dashboard card edge and pinned table header rule | per-mode | |
# theme.palette.axis.{light,dark} | Chart axis domain and tick lines | per-mode | |
# theme.palette.gridline.{light,dark} | Chart gridlines | per-mode | |
# theme.palette.chartText.{light,dark} | Chart axis, legend and title text | per-mode | |
# theme.palette.value.{light,dark} | Big-value (KPI) number color | per-mode | |
# theme.font.family | Font for all rendered text | shared | |
# theme.font.size | Table font size (px) | shared |
The thirteen per-mode palette.* color keys each take a .light and/or .dark variant so dark mode gets its own value. palette.series, font.family, and font.size are single values shared across modes (a .light or .dark on them does nothing). palette.mapColor recolors choropleths only; heatmaps keep their built-in scheme. No annotation sets the default light or dark mode or the user toggle: that lives in the instance theme. Environment-level theming is not applied yet.
// Model-wide defaults (## applies to every view in the model):## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"]## theme.font.family = "Inter, sans-serif"// Per-view override (# applies to this result only; beats the instance theme):# theme.palette.background.light = "#fafafa"# theme.palette.background.dark = "#111111"# theme.palette.tableHeader.dark = "#94a3b8"view: revenue_by_month is {group_by: monthaggregate: revenue}
Precedence, highest to lowest, per key: # theme.* on the view, then ## theme.* model default, then the instance theme, then Publisher's built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare @malloydata/render embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.)
Quote values that contain spaces or a leading #. The light/dark default (defaultMode) and the toggle lock (allowUserToggle) are instance-only: set them in the config theme block or the editor, not as annotations. Annotation forms that look valid but do nothing, such as a flat # theme.tableHeaderColor, are dropped without an error.
Advanced Patterns
Sparklines in KPI Cards
A sparkline needs two things: a # hidden nested view and a .sparkline= property naming it. If it does not show, check that # hidden is on the nested view and that its name matches .sparkline=.
# big_value { sparkline=trend }view: revenue_kpi is {aggregate:# label="Revenue"# currencyrevenuenest:# line_chart { size=spark }# hiddentrend is { group_by: order_date, aggregate: revenue, order_by: order_date }}
KPIs with Comparison Deltas
# big_value { comparison_field=prior_month comparison_label="vs Last Month" }view: rev_delta is {aggregate:# label="Revenue"# currencyrevenue# hiddenprior_month}
Use down_is_good=true for metrics where decrease is positive (churn, defects).
Inline Mini-Charts in Table Rows
view: carriers is {group_by: carrier, aggregate: flight_countnest: # line_chart { size=spark }trend is { group_by: month, aggregate: flight_count, order_by: month }}
Multi-Measure Series
# bar_chart { y=['revenue','cost'] }view: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }
Hierarchical Drill-Down
# list_detailview: explorer is {group_by: region, aggregate: revenuenest: # bar_chartby_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 }}
Distribution (Histogram)
There is no auto-binning function: autobin(...) does not exist and fails with Unknown function 'autobin'. Bin by arithmetic, choosing the width from the column's actual range: query min, max and a few percentiles first, and say in the view's doc where the width came from. A bin width nobody derived is a business decision in disguise.
# bar_chartview: price_dist is {group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed rangeaggregate: order_count is count()order_by: bucket}
Patterns for Missing Chart Types
| Desired | Malloy Approximation | |
|---|---|---|
| Pie/donut | # bar_chart sorted by value | |
| Treemap | Nested table with order_by: desc | |
| Heatmap | # pivot with color values | |
| Stacked area | # line_chart with series (overlaid lines) | |
| Funnel | # bar_chart with ordered stages | |
| Gauge/bullet | # big_value with .comparison_field |
Chart Annotations on Queries with nest:
A top-level chart tag (e.g., # bar_chart) renders only the outer query; any nest: views are silently hidden from the rendering (still in raw data). To show nests, use # dashboard on the outer query with chart tags on each nest. Otherwise, drop the nest:.
Common Mistakes
| Mistake | Fix | |
|---|---|---|
| Two aggregates in chart | ONE aggregate, or use y=['a','b'] | |
# currency=usd0m on measure | # currency (no scale) on defs; scale only in views | |
Several tags on a nest: line | One lone tag on the nest: line works; with several, put each tag on its own line above the nested view | |
| Tags on same line | One tag per line | |
| Sparkline not showing | Add # hidden to nested view AND reference in .sparkline= | |
| Pivot > 30 columns | Filter/limit the nested group_by |
NOTE: The term 'constructor' is a reserved term in Vega-Lite. If the word 'constructor' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model.
For more patterns, call search_malloy_docs with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots".
Further Reading
- Visualizations Overview - Official docs
- Bar Charts - Stacked, grouped, series
- Bump Charts Blog - Ranking over time
- Dataviz is Hierarchical - Nested data visualization philosophy