Skip to content

Custom charts

When no built-in type draws the shape you need, type: custom lets you draw it yourself. A custom chart is an SVG template: a Jinja file that turns the chart's query rows into SVG shapes. The template reads the board's style, so a custom chart picks up the theme's colors and fonts and follows a theme switch like any built-in chart.

Try the built-in types and composed charts first. A built-in chart gets axes, legends, hover, sizing rules, and visual warnings for free; a custom chart draws only what its template draws.

For ready-made templates (Sankey, funnel, gauge, radial bar, XmR, isotype, waffle, and radar), see Custom chart examples.


A first custom chart

A template can live inline, under svg:. This one draws one bar per salesperson against a 100% quota line:

charts:
  quota:
    type: custom
    title: Quota attainment
    query:
      columns: [rep, attainment]
      values:
        - [Ana, 1.18]
        - [Ben, 0.92]
        - [Chloe, 0.64]
        - [Dev, 1.03]
    svg: |
      {% set band = height / (rows | length) %}
      {% set scale = (width - 140) / 1.25 %}
      <g font-family="{{ style.font.family }}" font-size="{{ style.font.size }}">
      {% for r in rows %}
        {% set y = loop.index0 * band %}
        {% set hit = r.attainment >= 1 %}
        <text x="70" y="{{ y + band / 2 }}" dy="0.35em" text-anchor="end"
              fill="{{ style.font.color }}">{{ r.rep }}</text>
        <rect x="80" y="{{ y + band * 0.2 }}" height="{{ band * 0.6 }}"
              width="{{ r.attainment * scale }}"
              fill="{{ style.tones.positive if hit else style.charts.color.categorical.single_series_palette[0] }}">
          <title>{{ r.rep }}: {{ r.attainment | format('.0%') }}</title>
        </rect>
        <text x="{{ 86 + r.attainment * scale }}" y="{{ y + band / 2 }}" dy="0.35em"
              fill="{{ style.muted }}">{{ r.attainment | format('.0%') }}</text>
      {% endfor %}
        <line x1="{{ 80 + scale }}" x2="{{ 80 + scale }}" y1="0" y2="{{ height }}"
              stroke="{{ style.font.color }}" stroke-dasharray="3 3"/>
      </g>
rows:
  - quota
Quota Attainment Ana Ana: 118% 118% Ben Ben: 92% 92% Chloe Chloe: 64% 64% Dev Dev: 103% 103% Data as of 14:24 UTC on 6 Oct 2026 made withdbt Charts
▶

The template writes SVG content only. dbt Charts draws the title and subtitle, wraps the template's output in an <svg> of the chart's size, and places it on the board.


Template files

Inline templates suit a one-off chart. To reuse a chart across boards, put the template in your project's templates/ folder, next to dbt_charts.yml, and name it with template::

my_project/
├── dbt_charts.yml
├── charts/
│   └── funnel.yml
└── templates/
    └── funnel.svg.j2
queries:
  activation_funnel:
    columns: [stage, users]
    values:
      - [Visited site, 48210]
      - [Started signup, 12940]
      - [Upgraded, 740]
charts:
  activation:
    type: custom
    template: funnel
    query: activation_funnel
    title: Activation funnel
    x: stage
    y: users
rows:
  - activation

template: funnel reads templates/funnel.svg.j2. A chart sets exactly one of template: or svg:.

Finding templates

dct describe reads a template's header, so you never open the file to learn what it takes:

dct describe templates/sankey.svg.j2   # one template
dct describe templates/                # every template in the folder

Each template prints its description, its channels (required or optional, and whether a list is allowed), and its options (type, default, description). dct describe --json returns the same under a template key. Describing a board shows, for each custom chart, the template it uses and that template's description. A template: name with no file lists the templates the project does have, and an unsupported type: such as sankey names the matching template when the project has one.

Channels and options

A template file declares what it takes in a header: a Jinja comment that opens with {#--- and closes with ---#}, holding YAML. It has two parts.

Channels are the chart's data columns, named with the same channel fields built-in charts use: x, y, color, size, shape, theta, and value. A chart sets them at its root, exactly as it would on a bar or pie chart. Pick the channel that matches the role: a category along a funnel is x and its count is y, the way a bar chart reads; a part of a whole is color and theta, the way a pie reads.

Options are everything else the template can be told: a format, a maximum, a symbol. A chart sets them under options:.

{#---
description: Funnel of ordered stages, one row per stage in query order.
channels:
  x:
    description: Column naming each stage.
  y:
    description: Column holding the count that reached each stage.
options:
  value_format:
    type: string
    default: ",.0f"
  label_width:
    type: number
    default: 120
---#}
type: custom
template: funnel
x: stage
y: users
options:
  label_width: 140

A channel or option without a default is required; default: null makes it optional with no value, which a template tests with {% if options.target %}. A channel declared with multiple: true takes a list of columns (y: [disease, wounds]) and always reaches the template as a list. A template with no header, an inline one included, takes no channels and no options.

The chart must match the header. A channel or option the header doesn't declare, a missing required one, or a value of the wrong type is a validation error, so a typo fails when the board compiles rather than drawing something wrong. Every channel, and every column option, is also checked against the query's result columns when the chart renders.

Option type Accepts
column The name of a column in the chart's query, for a secondary role such as a gauge's target.
columns A list of column names.
string Any text.
number Any number.
integer A whole number.
boolean true or false.
color A color: hex, a CSS color name, or a palette token.

What a template sees

Name What it holds
rows The query result, one mapping per row: {{ r.revenue }} or {{ r[channels.y] }}. A chart with no query: gets no rows.
columns The result's column names, in order.
channels The columns the chart set for each declared channel: channels.x is a column name, or a list for a multiple channel; an optional channel left unset is none.
options The chart's options, with the header's defaults filled in.
width, height The drawing area in pixels, below the title.
uid A prefix unique to this chart. Use it for id values (gradients, clip paths, symbols) so two charts on one board never share an id.
style The board's style, resolved. See Style.
math math.sin, math.cos, math.tan, math.asin, math.acos, math.atan2, math.sqrt, math.log, math.exp, math.floor, math.ceil, math.pi, math.tau. Angles are in radians.

And these helpers:

Helper Returns
value \| format(spec) The value formatted with a d3-format spec, the same formats format: takes elsewhere: {{ 0.432 \| format('.1%') }} gives 43.2%.
text_width(text, size, weight) The width in pixels of text, a string, in the board's font at size pixels: text_width(r.name, style.font.size). Turn a number into text first, with \| string or \| format(...). weight is optional and defaults to 400. It takes any weight style can carry: a whole number from 1 to 1000 such as 600, or a CSS keyword such as bold or normal, so style.charts.title.font.weight works whatever the theme sets. Use it to fit or wrap labels.
color_for(value, column) The board's pinned color for that column and value, from style.charts.category_colors, so a pinned category matches the built-in charts on the board. A value with no pin gets the next color of the categorical palette, in the order the template asks; the same value always gets the same color within a chart, and running out of palette colors is an error.
raise_error(message) Stops the render and shows message in the chart's place. Use it to reject data the template cannot draw.

Templates use strict variables: a misspelled column, option, or style key is an error that names the template and line, not an empty string.

Query values arrive as the warehouse returns them. Some warehouses return decimals, so convert before doing arithmetic with floats: {{ (r.revenue | float) * scale }}.


Style

style has the same structure as the style: block you author on a board or in a theme, after the theme, the board's style:, and every inherited value are merged. So a key you can set is a key a template can read, at the same path:

In a template What it is
style.background The board background.
style.font.family, style.font.color, style.font.size Body text.
style.muted, style.accent Secondary text and the accent color.
style.tones.positive, .negative, .warning, .info Status colors.
style.charts.color.categorical.palette The categorical palette built-in charts use, as a list of colors.
style.charts.color.categorical.single_series_palette[0] The color of a single-series chart.
style.charts.axis.grid.color Gridlines and tracks.
style.charts.axis.labels.font Axis label text.
style.charts.title.font Chart title text.
style.palettes.<role> Each palette role, resolved: a list of colors for a categorical, sequential, or diverging palette, and a mapping of named stops (solid, bg, border, text, ...) for a tone.

Every color is a resolved hex value. The one difference from what you author is palettes:: you name a palette (category: editorial-10), and the template sees its colors.

Read colors and fonts from style rather than writing them into the template, and the chart follows every theme, dark ones included, without changes. The examples never hard-code a color.

To pin a category's color across every chart on a board, built-in or custom, use style.charts.category_colors and read it in the template with color_for(value, column):

style:
  charts:
    category_colors:
      qualification:
        values:
          Graduates: "#2b59a8"
          Certificated: "#d33a32"

What a template may draw

A template's output must be well-formed SVG built from drawing elements: shapes (rect, circle, ellipse, line, polyline, polygon, path), text (text, tspan, textPath), grouping (g, defs, symbol, use), paint (linearGradient, radialGradient, stop, pattern, clipPath, mask, marker), and title and desc.

The output is checked before it reaches the board, and anything outside that set is an error that names the element or attribute. Nothing is silently removed. Not allowed:

  • <script>, <foreignObject>, <style>, <image>, <a>, and animation elements such as <animate> and <set>
  • event attributes such as onclick
  • links out of the page: href must be a #id reference (href="#{{ uid }}-person"), and url(...) must be url(#id) (fill="url(#{{ uid }}-fade)")
  • comments, processing instructions, and CDATA sections
  • CSS escapes (a backslash in an attribute) and CSS functions other than url(#id) that load a resource, such as image-set()

Values written with {{ }} are escaped, so a label containing < or & draws as text.

A <title> inside a shape becomes its tooltip: hover over any mark in the examples to see one.


Writing good templates

Let the query do the math. Jinja is good at loops and text, and slow and awkward at algorithms. Sorting, ranking, running totals, shares, and binning belong in SQL. The XmR example computes its limits in the template only because they are a few sums; a layout that needs iteration is a sign the shape should be a built-in type.

Validate the data. Check what the template depends on and stop with raise_error when it is wrong: a gauge with more than one row, a Sankey whose flows loop back, a negative value in a waffle.

Use uid for every id. A board renders many charts into one document, so an id like fade would collide when a template is used twice.

Leave room for text. Measure labels with text_width instead of guessing, and keep a fixed margin for labels that sit outside the marks.


Errors

Code When
ERR-CUSTOM-TEMPLATE The template file is missing, its header is not valid YAML, or the template has a Jinja syntax error.
ERR-CUSTOM-FIELDS A channel or option the header doesn't declare, a missing required one, or a value of the wrong type.
ERR-CUSTOM-RENDER The template failed while drawing: a missing column, an undefined name, or a raise_error call.
ERR-CUSTOM-SVG The output is not well-formed SVG, or uses an element or attribute that is not allowed.