<< All versions

Skill v1.0.2

LLM-judged scan100/100
malloydata/publisher/malloy-charts

fix(server): hold caller-written joins to the joined source's gates, keep row filters bound to their field, and read keywords in any case (#1238)

──Details
PublishedSeptember 25, 2026 at 01:52 PM
Content Hashsha256:76ced4ac93a9d5a6...
Git SHA3620b2d94fa6
Compare with v1.0.1
──Files
Files (1 file, 16.1 KB)
SKILL.md16.1 KBactive
SKILL.md · 430 lines · 16.1 KB

version: "1.0.2" name: malloy-charts description: Chart selection for a Malloy view, plus the renderer reference for bar_chart, line_chart, scatter_chart, maps and layouts. For choosing a visualization or adding a chart annotation.


<!-- 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 notebook, a dashboard, 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
  • One tag per line
  • 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

# 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, # 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

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.font.familyFont for all rendered textshared
# theme.font.sizeTable font size (px)shared

The seven 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.

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. The malloy-gotchas-rendering skill lists the annotation forms that look valid but do nothing.

Advanced Patterns

Sparklines in KPI Cards

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
Chart annotation on nest: linePut on the view definition
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.1All versionsv1.0.3 →