Next to your models

Dashboards you can code review.

An open source language: the query, charts, and layout in one YAML file.

Quickstart
Paste into Claude Code, Codex, or any coding agent
I want to make charts of this data with dbt Charts. Run `uv tool install dbt-charts`, then `dct skills chart`, and follow what it prints.
A bar chart of documents per month, rendered from the file beside it.
charts/documents.yml
title: Documents by monthsource: dbqueries:  documents: |    SELECT DATE_TRUNC('month', created_at) AS month,           COUNT(*) AS docs    FROM documents WHERE created_at >= '2025-10-01'    GROUP BY 1charts:  documents:    query: queries.documents    type: bar    x: month    y: docs

The chart is only four lines.

In your dbt project

charts/ right next to your
models/

Run dct anywhere in a dbt project: it finds dbt_project.yml and uses the profiles.yml you already keep. No second repo, no second credentials.

my_dbt_project/
  .git/
  dbt_project.yml
  profiles.yml
  models/
  charts/
    users.yml
    pipeline.yml
    themes/
      newsroom.yml

Reviewed like code

Broken charts fail in CI

A board names the models it reads. So the usual dashboard-breaker, someone renaming a column, fails the pull request that renames it. Not a Slack message on Monday.

Structural, with no credentials

dct init ci writes a GitHub Actions workflow that runs dct validate on every push: schema checks with no warehouse connection, so it runs on a fork's pull request.

Against your dbt models

Add dbt parse and every query reaching a model through ref() is checked against the columns that model produces.

Against the warehouse itself

dct validate --warehouse asks the warehouse the cheapest way it can: DESCRIBE on DuckDB, a dry run on BigQuery, EXPLAIN on Postgres, Redshift, and Snowflake. No rows scanned.

On the pull request that renames the column

$ dbt parse && dct validate charts

ERR-DBT-MODEL-COLUMN-MISSING
  charts/users.yml:14
  Column 'signup_source' is not in ref('fct_users').
  Did you mean 'acquisition_source'?

1 board failed, 14 passed.

Or before you rename it at all

$ dct impact signup_source --table fct_users

2 boards read this column:
  charts/users.yml
  charts/growth/acquisition.yml

One system, not a pile

Charts that fit together.

Most chart libraries are separately invented things: every type has its own idea of a margin, a legend, a label. Put four on a board and nothing lines up.

A board on a three-column grid: a prose card and a line graph share the top row, and a column chart, a horizontal bar chart, and a donut share the row below, with the grid's border and gutters drawn over the render.
Every line is a card edge measured from the render. Nothing here was placed by hand.

One grid

A prose card, a two-column line chart, and three one-column charts share one three-column grid, with the same margins, gutters, and insets whatever the chart type.

One type scale

Title size follows card width, not chart type. Every chart title at the same width matches exactly.

One set of spacing rules

Plot padding, gutters, and card spacing come from the theme, not from whatever each chart type happened to be built with.

The range

Every one of these is YAML.

Rendered by the same engine, from the same syntax the rest of this page is written in. No plugin API, no escape hatch into JavaScript.

A board from the demo project: gallery/general/dundersign-commercial-finance.
A board from the demo project: customer-support/backlog/aging.
A board from the demo project: executive/growth/bookings-arr.
A board from the demo project: revenue-ops/leads/funnel.
A board from the demo project: people-talent/headcount/overview.
A board from the demo project: product-analytics/engagement/retention.
A board from the demo project: revenue-ops/pipeline/deal-velocity.
A board from the demo project: product-analytics/documents/lifecycle.
A board from the demo project: revenue-ops/accounts/list.
A board from the demo project: people-talent/people/pay-bands.
A board from the demo project: executive/health/unit-economics.
A board from the demo project: revenue-ops/leads/market-segments.

One line

Fully customizable themes

One line switches the theme, and it reaches everything: ground, palette, type, grid, spacing. Themes are files that cascade, project to theme to board to chart, so they diff.

The same board rendered in the clarity theme. The same board rendered in the paper theme.
theme: clarity The shipped default. Restrained ink on a white ground. theme: paper Warm off white and serif type, for something meant to be read.
The same board rendered in the neon theme. The same board rendered in the vivid theme.
theme: neon Dark ground and luminous marks, for a screen in a dim room. theme: vivid Saturated and high contrast, for a chart that has to carry a room.

A board, from scratch

Start with a query. Add only what you need.

The file on the left is the whole board; the right is what it renders. The highlighted line is the only thing each step changed. Each step shows the whole board file, with the lines that step changed highlighted, and the board it renders. Nothing here is a mockup.

  1. Step 1 · A query

    A board starts as a named query. Point a chart at it and you get the rows back in the order the SQL returned them.

    title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: table
    The board after step 1, a query.
  2. Step 2 · A chart

    Say how to see it. The chart layer never aggregates or reorders: it reads the columns the query produced, so nothing here can change the number.

    title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: scatter    x: month    y: users
    The board after step 2, a chart.
  3. Step 3 · Change one word

    Every chart type reads the same two columns, so trying another one is a one word edit rather than a rewrite.

    title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: bar    x: month    y: users
    The board after step 3, change one word.
  4. Step 4 · Change it again

    Nothing about the query moved. Only the type changed, and the axis, labels, and numbers follow on their own.

    title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: line    x: month    y: users
    The board after step 4, change it again.
  5. Step 5 · A second chart

    Add a query, add a chart, and list them. Rows stack them down the board, each one the full width.

    title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6  by_source: |    SELECT signup_source, COUNT(*) AS users    FROM users    GROUP BY 1 ORDER BY 2 DESCcharts:  users:    title: Total users    query: queries.users    type: line    x: month    y: users  by_source:    title: Where they came from    query: queries.by_source    type: bar    x: signup_source    y: usersrows: [users, by_source]
    The board after step 5, a second chart.
  6. Step 6 · Change the layout

    Columns put them side by side. That is the whole layout language: no drag targets, no pixel coordinates, and a diff that says what moved.

    title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6  by_source: |    SELECT signup_source, COUNT(*) AS users    FROM users    GROUP BY 1 ORDER BY 2 DESCcharts:  users:    title: Total users    query: queries.users    type: line    x: month    y: users  by_source:    title: Where they came from    query: queries.by_source    type: bar    x: signup_source    y: userscols: [users, by_source]
    The board after step 6, change the layout.
charts/users.yml
title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: table
title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: scatter    x: month    y: users
title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: bar    x: month    y: users
title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6charts:  users:    query: queries.users    type: line    x: month    y: users
title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6  by_source: |    SELECT signup_source, COUNT(*) AS users    FROM users    GROUP BY 1 ORDER BY 2 DESCcharts:  users:    title: Total users    query: queries.users    type: line    x: month    y: users  by_source:    title: Where they came from    query: queries.by_source    type: bar    x: signup_source    y: usersrows: [users, by_source]
title: User growthsource: dbqueries:  users: |    SELECT DATE_TRUNC('month', created_at) AS month,           SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users    FROM users    GROUP BY 1 ORDER BY 1 LIMIT 6  by_source: |    SELECT signup_source, COUNT(*) AS users    FROM users    GROUP BY 1 ORDER BY 2 DESCcharts:  users:    title: Total users    query: queries.users    type: line    x: month    y: users  by_source:    title: Where they came from    query: queries.by_source    type: bar    x: signup_source    y: userscols: [users, by_source]
The board after step 1, a query. The board after step 2, a chart. The board after step 3, change one word. The board after step 4, change it again. The board after step 5, a second chart. The board after step 6, change the layout.

A board starts as a named query. Point a chart at it and you get the rows back in the order the SQL returned them.

Say how to see it. The chart layer never aggregates or reorders: it reads the columns the query produced, so nothing here can change the number.

Every chart type reads the same two columns, so trying another one is a one word edit rather than a rewrite.

Nothing about the query moved. Only the type changed, and the axis, labels, and numbers follow on their own.

Add a query, add a chart, and list them. Rows stack them down the board, each one the full width.

Columns put them side by side. That is the whole layout language: no drag targets, no pixel coordinates, and a diff that says what moved.

Two declarations

SQL declares what data to show.Now declare how to show it.

Charting never got the same treatment: a GUI that hides the decisions, or a plotting API where the answer depends on call order. dbt Charts is the missing half: a declarative language for how data is seen.

Neither half hides logic from the other. The query owns what the numbers mean; the chart owns nothing but how they look.

charts/revenue.yml
title: Revenue by month
source: db

charts:
  revenue:
    query: |
      SELECT
        date_trunc('month', order_date) AS month,
        sum(revenue) AS revenue
      FROM fct_orders
      GROUP BY 1    type: bar
    x: month
    y: revenue

Filters and links

The filters are in the file too.

A board declares its filter variables beside its queries. Each one renders as a control, and its value is a query parameter, so a filtered board is a URL you can paste into a thread. link: on a chart makes every bar, point, and row a link: to a different filtered view of the same board, or a jump to another board.

The Pipeline by channel board with Channel set to Website: a bar chart of the top channels above a line of monthly pipeline for Website. The Pipeline by channel board with Channel set to Webinar: a bar chart of the top channels above a line of monthly pipeline for Webinar. The Pipeline by channel board with Channel set to Referral: a bar chart of the top channels above a line of monthly pipeline for Referral.
charts/pipeline.yml
title: Pipeline by channelsource: dbwidth: 720variables:  channel:    column: opportunities.lead_sourcequeries:
⋯ 2 queries by_channel: | SELECT lead_source AS channel, SUM(amount) AS pipeline FROM opportunities WHERE created_at >= '2025-10-01' GROUP BY 1 ORDER BY 2 DESC LIMIT 5 by_month: | SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS pipeline FROM opportunities WHERE created_at >= '2025-10-01' AND {{ filter('lead_source', channel) }} GROUP BY 1 ORDER BY 1
charts: by_channel: title: Top channels, Oct 2025 to Aug 2026 query: queries.by_channel type: bar x: channel y: pipeline height: 200 link: "?channel={{ x }}" by_month: title: "By month, {{ channel or 'all channels' }}" query: queries.by_month type: line x: month y: pipeline height: 230rows: [by_channel, by_month]

Click a bar, or pick from the menu. The URL is the filter.

Filters are variables

Declare a filter variable, wire it into SQL with filter(), and it renders as a control: a dropdown, a multiselect, a date range with presets, a slider, a checkbox. A variable nobody has set filters nothing.

The URL is the state

Every variable is a query parameter, so ?channel=Referral is a filtered board, and a filtered board is a link you can paste into a thread. A hidden variable works the same way, which is how one detail board serves every account.

A click is a link

link: on a chart makes every bar, point, and row a link. ?channel={{ x }} filters the charts beside it in place; a path opens another board with the clicked value carried along. Tables link a whole row, or one column.

Prose

Reports, not just dashboards

We believe more dashboards will be reports with extensive text analysis, and we've built first class prose support with markdown.

  - text: |
      ## Why Q3 turned

      Two things moved: enterprise renewals landed early, and
      **self-serve churn** fell for the first time in a year.
      Signups held their climb through the quarter, so the lift
      is retention, not acquisition.
A Q3 growth review: a written read, three KPIs, a falling self-serve churn line and early enterprise renewals each with a paragraph of analysis, what-to-watch notes above a table of churn by plan, a donut of new users by channel, and a bottom-line conclusion.

Visualization linting

Visual feedback for the AI

Agents can be a bit blind. We detect common visualization blunders and raise errors or warnings, so they can ensure an excellent final version.

$ dct render charts/revenue.yml

WARN-BAR-BAND-WIDTH-TOO-NARROW
  charts/revenue.yml:14
  182 bands x 2 series across 640px
  (~1.4px per bar), so bars will read as
  a merged block instead of separate marks.
  Fix: roll up to a coarser grain (day -> week).

WARN-TABLE-COLUMNS-OVERFLOW
  charts/revenue.yml:31
  Table needs 980px but only 640px is available.
  Fix: drop columns or widen the slot.

Two minutes to a board

Have your agent use dbt Charts.

Everything above is what an agent gets for free: a language it can write, validation that says when it is wrong, and a render it can look at. Say the sentence in Claude Code, Codex, or any coding agent with a terminal, and dct skills chart teaches it the rest. Prefer to type? The install is one line.

Claude Code
Codex · others
I want to make charts of this data with dbt Charts. Run `uv tool install dbt-charts`, then `dct skills chart`, and follow what it prints.
Terminal uv tool install dbt-charts

In claude.ai or ChatGPT, with nothing to install? Start on dbtCharts.com ↗

Agents that arrive on their own read dbtCharts.com/llms.txt.

A board in the dbtCharts.com workspace with a chart selected in the visual design controls

Hosted

The platform

Turn a working board into a shared place for analysis. Build with an agent, refine in the visual editor, publish to the people who rely on it. One open definition connects every step.

  • Conversational analytics on your warehouse
  • A visual editor for the finishing touches
  • Sharing with permissions. Free to start.
Explore the platform →

Your first chart is four lines.

One sentence to Claude Code, Codex, or any coding agent, or one install at the terminal. The tool teaches the agent the rest.

Claude Code
Codex · others
I want to make charts of this data with dbt Charts. Run `uv tool install dbt-charts`, then `dct skills chart`, and follow what it prints.
Terminal uv tool install dbt-charts

In claude.ai or ChatGPT, with nothing to install? Start on dbtCharts.com ↗