<< All versions

Skill v1.0.3

currentAutomated scan100/100
malloydata/publisher/malloy-charts

+1 new, ~3 modified

──Details
PublishedOctober 31, 2026 at 03:17 PM
Content Hashsha256:b4aa5d17261a848b...
Git SHA4fc712bdb6b2
Bump Typepatch
Compare with v1.0.2
──Files
Files (1 file, 17.8 KB)
SKILL.md17.8 KBactive
SKILL.md · 453 lines · 17.8 KB

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. Call search_malloy_docs with 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 ShapeDefault 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 rowsDefault table (no annotation)
GoalRenderer
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 categoryDefault 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 # currency not # currency=usd0m. The same measure renders at many granularities, and usd0m turns $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_by before order_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.

malloy
# bar_chart
view: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 }
# bar_chart.stack
view: 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.

malloy
# line_chart
view: 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.

malloy
# scatter_chart
view: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity }

# shape_map

Choropleth. US states only. Fields: state name, value.

malloy
# shape_map
view: 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.

malloy
# big_value
view: summary is {
aggregate:
# label="Revenue"
# currency
revenue
# label="Orders"
# number=auto
order_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_by dimensions -> a row header (repeats once per row; omit for a single block)
  • aggregate measures -> KPI cards, one per measure
  • each nest: -> a tile, rendered by the tag above it (# table default, 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.

malloy
// Flex: measures become KPI cards, the nest becomes a tile
# dashboard
view: overview is {
group_by: category
# currency
aggregate:
avg_retail is retail_price.avg()
sum_retail is retail_price.sum()
nest:
# bar_chart
by_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
# currency
aggregate:
# colspan=4
avg_retail is retail_price.avg()
# colspan=4
sum_retail is retail_price.sum()
# colspan=4
max_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=6
by_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:

malloy
# dashboard
view: kpis is {
group_by: category
nest:
# big_value
revenue_card is {
aggregate:
# label="Revenue"
# currency
# big_value { sparkline=trend }
total_revenue is retail_price.sum()
# line_chart { size=spark y.independent=true }
# hidden
nest: 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.

malloy
view: sales is {
group_by: product, aggregate: total
nest: # pivot
by_quarter is { group_by: quarter, aggregate: revenue }
}

# transpose

Swap rows/columns. Good for period comparisons.

malloy
# transpose
view: 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:

malloy
view: segments is {
group_by: product, aggregate: total_revenue
nest: # flatten
enterprise is { where: segment = 'Enterprise', aggregate: # label="Enterprise" revenue }
nest: # flatten
smb is { where: segment = 'SMB', aggregate: # label="SMB" revenue }
}

# table

Default (implicit). Use explicitly for .size=fill property.

Field Formatting Tags

TagUse ForShorthand
# numberNumeric formatting=auto (K/M/B), =id (no commas), =1k, =1m
# percentPercentages(none needed)
# currencyMoney=usd2m (USD, 2 decimals, millions); scale only in views
# durationTime durations=seconds, =minutes, =hours, =days
# data_volumeStorage sizes=bytes, =kb, =mb, =gb
# linkHyperlinks.url_template="https://example.com/$$"
# imageInline 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

TagPurpose
# hiddenHide from output (still usable for sorting/references)
# label="..."Override display name
# description="..."Tooltip text
# tooltipInclude nested view in chart tooltip
# breakForce new dashboard row
# column { width=sm }Table column width

Model-Level Defaults

malloy
## 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:

AnnotationControlsModes
# theme.palette.seriesCategorical series colors (array)shared
# theme.palette.background.{light,dark}Chart canvas + table backgroundper-mode
# theme.palette.tableHeader.{light,dark}Table header text colorper-mode
# theme.palette.tableHeaderBackground.{light,dark}Table header row backgroundper-mode
# theme.palette.tableBody.{light,dark}Table body text colorper-mode
# theme.palette.tile.{light,dark}Dashboard tile backgroundper-mode
# theme.palette.tileTitle.{light,dark}Dashboard tile title colorper-mode
# theme.palette.mapColor.{light,dark}Choropleth gradient (# shape_map / # segment_map)per-mode
# theme.palette.border.{light,dark}Table gridlines and row rulesper-mode
# theme.palette.cardBorder.{light,dark}Dashboard card edge and pinned table header ruleper-mode
# theme.palette.axis.{light,dark}Chart axis domain and tick linesper-mode
# theme.palette.gridline.{light,dark}Chart gridlinesper-mode
# theme.palette.chartText.{light,dark}Chart axis, legend and title textper-mode
# theme.palette.value.{light,dark}Big-value (KPI) number colorper-mode
# theme.font.familyFont for all rendered textshared
# theme.font.sizeTable 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.

malloy
// 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: month
aggregate: 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=.

malloy
# big_value { sparkline=trend }
view: revenue_kpi is {
aggregate:
# label="Revenue"
# currency
revenue
nest:
# line_chart { size=spark }
# hidden
trend is { group_by: order_date, aggregate: revenue, order_by: order_date }
}

KPIs with Comparison Deltas

malloy
# big_value { comparison_field=prior_month comparison_label="vs Last Month" }
view: rev_delta is {
aggregate:
# label="Revenue"
# currency
revenue
# hidden
prior_month
}

Use down_is_good=true for metrics where decrease is positive (churn, defects).

Inline Mini-Charts in Table Rows

malloy
view: carriers is {
group_by: carrier, aggregate: flight_count
nest: # line_chart { size=spark }
trend is { group_by: month, aggregate: flight_count, order_by: month }
}

Multi-Measure Series

malloy
# bar_chart { y=['revenue','cost'] }
view: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost }

Hierarchical Drill-Down

malloy
# list_detail
view: explorer is {
group_by: region, aggregate: revenue
nest: # bar_chart
by_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.

malloy
# bar_chart
view: price_dist is {
group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range
aggregate: order_count is count()
order_by: bucket
}

Patterns for Missing Chart Types

DesiredMalloy Approximation
Pie/donut# bar_chart sorted by value
TreemapNested 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

MistakeFix
Two aggregates in chartONE aggregate, or use y=['a','b']
# currency=usd0m on measure# currency (no scale) on defs; scale only in views
Several tags on a nest: lineOne lone tag on the nest: line works; with several, put each tag on its own line above the nested view
Tags on same lineOne tag per line
Sparkline not showingAdd # hidden to nested view AND reference in .sparkline=
Pivot > 30 columnsFilter/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

← v1.0.2All versions