Layered Charts¶
bar, line, area, and scatter charts accept a layers: field to overlay
additional marks (for example a bar and a line) on one shared x-axis.
They're the way to build combo charts and dual-axis charts, where two
series with very different scales (say, revenue in dollars and a conversion
rate in percent) each get their own y-axis instead of one squashing the other.
There is no separate type: layered; the base chart is a real chart type
(bar, line, area, or scatter) that sets the primary mark, x, y, and
query; additional marks go in layers:.
Minimum Required for a Combo Chart¶
| dbt Charts field | Notes |
|---|---|
type |
bar, line, area, or scatter; the base (primary) mark. |
x |
Shared x-axis column across the base and every layer. |
y |
Base chart's own y column. |
layers |
List of overlay layers. Each layer needs its own type (bar, line, area, or scatter) and y. |
Minimum Example¶
queries: monthly_funnel: columns: [month, revenue, conversion_pct] values: - ["2026-01", 42000, 3.1] - ["2026-02", 51000, 3.4] - ["2026-03", 68000, 4.2] - ["2026-04", 60000, 3.9] charts: revenue_vs_conversion: query: monthly_funnel type: bar title: Revenue vs. Conversion Rate x: month y: revenue layers: - type: line y: conversion_pct rows: - revenue_vs_conversion
Without any extra configuration, the base chart and its layers share a single y-axis, which works fine when the series are on a similar scale, but squashes one series flat when they aren't (as with revenue vs. a percentage above).
Dual Y-Axis¶
Each layer can pin its y-axis to a side with axis_y.position: left or
right. Pinning any layer gives the layers their own independent scales. A
layer pinned to one side puts the base chart's axis on the other.
Two scales can show a relationship that isn't there
Each axis is ranged on its own, so where the marks cross, which one sits
higher, and how closely they track come from the axis ranges, not the
data. dbt Charts warns about this with WARN-DUAL-AXIS-COMPETING-SCALES.
Alternatives:
- Show each measure in its own chart, stacked so they share the x-axis.
- Index both series to a common start (for example 100) in the query so they share one axis.
- Use a scatter to show how one measure moves with the other.
- For the same quantity in another unit (°F/°C, $/€), remove the layer and
relabel the chart's opposite edge with
style.axis_y.mirrorand anexprthat converts the value, such asformat(datum.value * 1.8 + 32, '.0f'): one scale, two unit labels. - When your audience expects the form, list the code in the chart's
warnings_ignore:.
queries:
monthly_funnel:
columns: [month, revenue, conversion_pct]
values:
- ["2026-01", 42000, 3.1]
- ["2026-02", 51000, 3.4]
- ["2026-03", 68000, 4.2]
- ["2026-04", 60000, 3.9]
charts:
revenue_vs_conversion:
query: monthly_funnel
type: bar
title: Revenue vs. Conversion Rate
x: month
y: revenue
y_label: Revenue ($)
layers:
- type: line
y: conversion_pct
axis_y:
position: right
title: Conversion (%)
rows:
- revenue_vs_conversion
| dbt Charts field | Allowed values | Notes |
|---|---|---|
layers[].axis_y.position |
left or right |
Which side this layer's y-axis renders on. |
layers[].axis_y.title |
String | Axis title override for this layer's side. |
Setting only title (no position) keeps a single shared axis. Setting
position on any layer, whichever side, makes the scales independent.
Layer Fields¶
Each entry under layers: accepts:
| Field | Notes |
|---|---|
type |
bar, line, area, or scatter. |
y |
Layer's y column (required). |
x |
Layer's own x-values, extending the base x-scale. Defaults to the base chart's x. |
query |
Overrides the base chart's query for this layer; lets a layer plot a different table/query (for example, actuals vs. targets). |
label |
Legend label for this layer's series. |
color |
Data channel (bare field name) for per-datum color. |
axis_y |
position (left/right) and title; see Dual Y-Axis above. |
style |
Marks-only style patch for this layer. |
Vega-Lite encoding: is not allowed inside a layer; use the typed channels
above instead.
Group an Overlay by Color¶
Set a layer's color to a column from that layer's query to draw one colored
series per value. This is useful when the base chart shows a total while the
overlay breaks a target or benchmark out by region, team, or segment.
queries:
monthly_revenue:
columns: [month, revenue]
values:
- ["2026-01", 42000]
- ["2026-02", 51000]
- ["2026-03", 68000]
regional_targets:
columns: [month, target, region]
values:
- ["2026-01", 40000, East]
- ["2026-01", 38000, West]
- ["2026-02", 50000, East]
- ["2026-02", 47000, West]
- ["2026-03", 65000, East]
- ["2026-03", 61000, West]
charts:
revenue_vs_regional_targets:
query: monthly_revenue
type: bar
title: Revenue and Regional Targets
x: month
y: revenue
layers:
- type: line
query: regional_targets
y: target
color: region
rows:
- revenue_vs_regional_targets
The line layer is grouped by region; its East and West series receive
separate colors and legend entries. The base revenue bars remain a separate
series in the same legend.
Trend Lines¶
Fit a trend line in SQL and overlay it as a line layer. This is the
recommended way to add one, not a workaround: the query layer already owns
aggregation and grain, so a fitted regression is just another computed
series, the same as a rolling average or a GROUP BY. Keeping the fit in SQL
also means the trend line always reflects the exact rows the base chart
plots, since both come from the same source data.
Most warehouses ship a linear-regression aggregate for this: REGR_SLOPE(y, x)
and REGR_INTERCEPT(y, x) compute the slope and intercept of the
least-squares line over (x, y) pairs, and both work as window functions
with OVER (), so the fit and the per-row detail come out of one query.
DuckDB, Postgres, and Snowflake support them natively. BigQuery and Redshift
ship neither; there, spell the fit out in plain arithmetic, which works on
both:
On BigQuery you can use COVAR_POP(y, x) / VAR_POP(x) for the slope instead.
Redshift has no covariance aggregate at all — it lists COVAR_POP alongside
the REGR_* family among the PostgreSQL functions it does not support — so
the arithmetic form above is the one to use there.
source: db
queries:
monthly_revenue:
sql: |
SELECT month, revenue
FROM dundersign_serving.monthly_metrics
ORDER BY month
revenue_trend:
sql: |
WITH indexed AS (
SELECT
month,
revenue,
ROW_NUMBER() OVER (ORDER BY month) AS month_index
FROM dundersign_serving.monthly_metrics
)
SELECT
month,
REGR_INTERCEPT(revenue, month_index) OVER ()
+ REGR_SLOPE(revenue, month_index) OVER () * month_index AS trend_revenue
FROM indexed
ORDER BY month
charts:
revenue_vs_trend:
query: monthly_revenue
type: line
title: Revenue vs. Trend
x: month
y: revenue
style:
number_format: currency_whole
layers:
- type: line
query: revenue_trend
y: trend_revenue
label: Trend
rows:
- revenue_vs_trend
revenue_trend numbers each row with ROW_NUMBER() rather than a date
function, since date-arithmetic syntax differs by warehouse. It then fits a
straight line against that index and plots the fitted values as their own
series, sharing the base chart's month x-axis via query: on the layer (see
Layer Fields above). Point this pattern at any query with a
stable grain and it travels unchanged.
A built-in trend option is being explored separately. This SQL-and-layer pattern is correct today, and will remain a valid, lower-level way to compute a trend series even after a built-in option ships.
Target & Reference Lines¶
dbt Charts automatically draws certain reference lines on bar, line, area, scatter, and layered charts; you don't author these, but it helps to know when they appear so they aren't mistaken for a data mark:
| Reference line | When it appears |
|---|---|
| Zero baseline | Bar charts get one whenever the visible y-range includes zero, which it normally does, since a bar's length is measured from zero. Line, area, and scatter charts get one on the same condition; turn it off with style.axis_y.scale.continuous.zero: false, which refits the range to the data. No chart gets one when the visible y-range excludes zero, whether you pinned the range yourself with scale.continuous.domain or the chart fitted it to data that never reaches zero: there is no position on the plot to draw it at. A bar layer inside a layered chart follows the shared scale the same way rather than overriding it. On a dual-axis chart, the base and each layer pinned to the opposite side are evaluated independently against their own data by these same rules; a layer sharing the base's side doesn't draw a separate one. A normalize-stacked (stack: normalize) chart shows 0%/100% lines instead (see below); a streamgraph (stack: center) chart and a chart under multiples: {scale: independent} show neither. On a horizontal bar this line renders as a vertical rule on the x channel, but it is still governed by style.axis_y, the axis that carries the measure regardless of orientation. Suppress it on its own with grid.threshold.visible: false, which leaves the axis's other gridlines in place. |
| 0% / 100% top lines | Normalize-stacked bar and area charts (stack: normalize), in place of the plain zero baseline. |
| 100% unity line (measure axis) | Bar, line, area, scatter, and layered charts formatted as a percent (number_format: percent_whole or similar), when the chart's effective measure domain reaches 1.0; the common guide for retention, conversion, and NRR charts stored as ratios. |
| 0 / 100% lines on a quantitative x axis | Independent of the measure axis above: any chart with its own quantitative x column (for example a scatter plotting one ratio against another) earns its own zero baseline when 0 falls inside the x-domain, and its own unity line when x is itself percent-formatted and the x-domain reaches 1.0. A horizontal bar's x is already the measure axis covered by the rows above, so it never gets a second, independent x rule. |
These reference lines pick up their color and stroke width from the relevant axis's grid.threshold (see Grid Lines): style.axis_y.grid.threshold for the measure-axis rows above, style.axis_x.grid.threshold for the independent quantitative-x rows. Each family can be suppressed on its own axis with grid.threshold.visible: false, without touching that axis's other gridlines; a blanket grid.visible: false on the same axis suppresses the reference line too.
There is currently no way to author a fixed, arbitrary threshold or target line (for example a sales quota drawn as a flat line across the whole chart) beyond the automatic reference lines above.