Skip to content

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.mirror and an expr that converts the value, such as format(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
Jan2026FebMarApr020,00040,00060,000Revenue ($)01234Conversion (%)Revenue ($)conversion (%)Revenue vs. Conversion Rate Data as of 14:24 UTC on 6 Oct 2026 made withdbt Charts
▶
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
Jan2026FebMar020,00040,00060,00080,000revenueEastWestRevenue and Regional Targets Data as of 14:24 UTC on 6 Oct 2026 made withdbt Charts
▶

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:

slope     = (AVG(x*y) - AVG(x)*AVG(y)) / (AVG(x*x) - AVG(x)*AVG(x))
intercept = AVG(y) - slope * AVG(x)

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
Sep2025OctNovDecJan2026FebMarAprMayJunJulAug$0$1,000$2,000$3,000$4,000revenueTrendRevenue vs. Trend Data as of 14:24 UTC on 6 Oct 2026 made withdbt Charts
▶

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.