Semantic layer

Define your metrics and dimensions once, then reference them straight from a component — no per-chart SQL, no copy-pasted queries:

<BarChart  metric={sales.revenue} by={sales.region} />
<LineChart metric={sales.revenue} by={sales.month} />
<PieChart  metric={sales.orders}  by={sales.region} />

One definition of revenue drives every chart. Change it in the model and every chart on every page follows. Dashboard filters become semantic filters automatically, and the query is pushed down to your database — the aggregation runs in the engine, not in Python.

Experimental (preview)

The first-class semantic layer is a preview feature. The grammar and model format are stable enough to try, but the surface may still change.

Choose a backend #

Dashdown doesn't ship its own semantic engine — it delegates to a pluggable backend, chosen per model with backend: (or auto-detected from the connector). Every backend sits behind the same metric={…} by={…} grammar and filter mapping, so a chart looks identical whichever engine a model uses:

Backend Engine Best for Extra Guide
ibis (default) BSL on Ibis Models over your own SQL warehouse — DuckDB, Postgres, MySQL, Snowflake, BigQuery — compiled to SQL and pushed down dashdown-md[semantic] BSL / Ibis →
cube (preview) Cube Reaching an existing Cube deployment over its JSON API dashdown-md[cube] Cube →

The backends define and connect their models differently, so head to a guide to define one — the rest of this page is the grammar that's shared across all of them. A third party can add another backend as a separate package; the registry is the public extension point, mirroring data connectors.

Reference it from a component #

The examples below assume a sales model — see BSL / Ibis → Define a model (or Cube) for how to declare one. Every data display takes metric={model.metric} (and, except a scalar Counter / Value, by={model.dimension}) instead of data={query} — charts, <Counter>, <Value>, and <Table>:

<!-- Charts -->
<BarChart metric={sales.revenue} by={sales.region} title="Revenue by region" />
<PieChart metric={sales.orders}  by={sales.region} title="Orders by region" />

<!-- KPI tiles / inline value — a metric with no `by` is a single scalar -->
<Counter metric={sales.revenue} label="Revenue" />
<Value metric={sales.revenue} />

<!-- A KPI tile whose sparkline is also a metric, bucketed by the time dimension -->
<Counter metric={sales.revenue} label="Revenue"
         sparkline={sales.revenue} sparkline-by={sales.order_date} grain="month" />

<!-- A table — one row per group -->
<Table metric={sales.revenue} by={sales.region} />

A <Counter> sparkline can be driven by a metric too — sparkline={model.metric} plus sparkline-by={model.time_dimension} (and an optional grain=) builds the bucketed trend with no hand-written series query. See Counter → Sparklines from a metric.

Filters re-query every metric component, KPIs included — pick a region and the counters, the value, the table, and the charts all update together.

The model.metric / model.dimension names are validated at render time — an unknown metric or dimension shows an inline error card, not a 500.

Note

Filter controls (Dropdown/Search/DateRange) and the cross-tab PivotTable keep their own data={query} interface — they drive or pivot data rather than display a single metric.

Multiple metrics & a second dimension #

Charts take the same two grouping shapes as a data={query} chart — driven by metrics and dimensions instead of columns:

<!-- Several metrics of one model → one coloured series each -->
<BarChart metric="sales.revenue,sales.avg_deal" by={sales.region} title="Revenue vs avg deal" />

<!-- A second dimension (series=) → split one metric into a series per value -->
<BarChart metric={sales.revenue} by={sales.region} series={sales.status} title="Revenue by region, by status" />

<!-- Faceted pies: one pie per series= value, sharing a slice legend -->
<PieChart metric={sales.revenue} by={sales.region} series={sales.status} title="Region mix by status" />

Use several metrics (comma-separated, quoted) when you want different measures side by side, or a second dimension (series={model.dim}) when you want one measure split by a category. They're mutually exclusive — combining a series= with multiple metrics raises an inline error.

Charts with named roles #

A few charts position several measures into named roles instead of a single y — and they take metric refs there too, combined into one query the same way:

<!-- OHLC: four measures grouped by a date dimension -->
<CandlestickChart by={prices.day}
                  open={prices.open} high={prices.high}
                  low={prices.low} close={prices.close} />

<!-- Heatmap: two dimensions (x/y) + a cell measure -->
<HeatmapChart x={sales.month} y={sales.channel} value={sales.downloads} />

<!-- Sankey / Graph: source + target dimensions + a link-weight measure -->
<SankeyChart source={flow.stage_from} target={flow.stage_to} value={flow.users} />

<!-- Parallel: one measure per axis, grouped into a polyline by `by` -->
<ParallelChart by={products.category}
               dimensions="products.price,products.weight,products.rating" />

This mirrors how a BI tool binds an OHLC or heatmap visual to a semantic model: N measures grouped by up to two dimensions, each measure mapped to a visual role. ComboChart (bars=/lines=) follows the same pattern.

Not every chart fits. Distribution charts (BoxPlot/Violin) need raw rows and hierarchy charts (SunburstChart/TreeChart) need an id/parent tree — neither is a measure-by-dimension shape, so they stay data={query} only, and a metric= on them shows an actionable error pointing back to data={query}.

Time grain — grain= #

Put a date on an axis and bucket it on demand with grain= — there's no need to pre-declare a month / quarter / year dimension. The model has one real time dimension; grain= chooses how to truncate it, per chart:

<LineChart metric={sales.revenue} by={sales.order_date} grain="month" />
<BarChart  metric={sales.revenue} by={sales.order_date} grain="quarter" />

The vocabulary is one neutral token set — second, minute, hour, day, week, month, quarter, year — and each backend translates it to its native mechanism, so the grammar is identical everywhere:

Backend grain="month" becomes
ibis (BSL) model.query(…, time_grain="TIME_GRAIN_MONTH") — Ibis .truncate(), pushed down; validated against the dimension's smallest_time_grain
cube timeDimensions: [{ dimension, granularity: "month" }] — Cube's native granularity

Two ways to set it, following the usual key="lit" vs key={ref} attribute rule:

<!-- Literal: fixed per chart. Two grains on one page are independent queries. -->
<LineChart metric={sales.revenue} by={sales.order_date} grain="month" />

<!-- Reference: a control drives it, so a reader re-buckets the chart live. -->
<TimeGrain name="trendGrain" default="month" />
<LineChart metric={sales.revenue} by={sales.order_date} grain={trendGrain} />

grain= composes with series= (bucket by month and split by category). On a <Counter> / <Value> headline it's a no-op (a scalar has no time grouping), but a <Counter> sparkline uses it to bucket its sparkline-by= time dimension — see Counter → Sparklines from a metric.

Grain is a grouping, not a filter

grain= changes the GROUP BY shape, never a WHERE clause — so a grain control is not a semantic filter (its name isn't a model dimension, so the filter compiler ignores it; it won't show in a widget's "filtered by" badge). The dedicated <TimeGrain> control is the ergonomic switcher (nice labels, validated tokens, a real default); a plain <Dropdown> whose option values are the canonical tokens works too.

Filters become semantic filters #

A Dropdown (or any filter) whose name matches a model dimension automatically narrows every chart on that model — no per-chart wiring:

<Dropdown name="region" label="Region" multi options="East,West,North,South" />

<BarChart metric={sales.revenue} by={sales.region} />

Picking regions re-queries the chart with a region IN (…) filter run by the backend. The project-wide global date filter maps onto the model's time dimension (order_date above) the same way. Filter values reach the model as typed filter values, never interpolated SQL — there is no ${param} injection surface.

Trust boundary #

Loading a semantic/*.yml builds a model in-process — the same trust boundary as Python queries and custom components, gated by the same switch:

# dashdown.yaml
python_queries:
  enabled: false   # default true — also disables the semantic layer

Model expressions stay server-side and never reach the browser.

Generated