Next to your models

Dashboards you can code review.

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

Read the docs uv tool install dbt-charts
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.

Two minutes to a board

One install. One sentence to your agent.

dct is a Python CLI. Install it, then write the YAML yourself or say the sentence to Claude Code, Codex, or Cursor: the tool teaches the agent the rest, in your repo or a fresh folder.

Terminal uv tool install dbt-charts
Claude / AI Make charts of this with dbt charts. Start with: uv tool install dbt-charts && dct skills intro

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

Two declarations

SQL declares what to see. Now declare how to see it.

SQL is declarative. You do not tell the warehouse how to fetch the rows, you say which rows you want and it works the rest out. That is the whole reason it has outlived every tool built on top of it.

Charting never got the same treatment. You get a GUI that hides the decisions, or an imperative plotting API where the answer depends on the order you called things in. dbt charts is the missing half: a declarative language for how the data should be seen, wrapped around the one you already write.

What you want

SELECT date_trunc('month', order_date) AS month,
       sum(revenue) AS revenue
FROM fct_orders
GROUP BY 1

How you want to see it

type: bar
x: month
y: revenue
stack: normalize

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

A board, from scratch

Start with a query. Add only what you need.

The file on the left is the whole board, not an excerpt, and the board on the right is what that file renders. Nothing here is a mockup, and the highlighted line is the only thing each step changed.

charts/documents.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. The query owns what the numbers mean.

Say how to see it. The chart layer never aggregates and never reorders: it reads the columns the query already produced, so nothing you write 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. The type is the only line that differs from the step before, and the axis, the labels and the numbers all 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.

One system, not a pile

Charts that fit together.

Most chart libraries are a collection of separately invented things: every type has its own idea of a margin, a legend, a label. Put four of them on a board and nothing lines up. Ours share one grid, one type scale and one set of spacing rules, so a board reads as a page rather than a scrapbook.

A board of two charts side by side, with alignment guides drawn over it.

One grid

Charts snap to the same column grid, so edges line up across a row without anyone nudging a margin.

One type scale

A title is the same size on every chart type. Axis labels, legends and annotations all resolve from the same ramp.

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 you just watched being built up. No plugin API, no escape hatch into JavaScript.

Support Backlog Aging
Top-of-Funnel Health
Accounts
Customer Satisfaction (CSAT)
Bookings & Recurring Revenue
Unit Economics
Product Adoption Growth
Headcount & Growth
Time to Hire
Engagement & Retention
Document Lifecycle: Created → Sent → Completed
Deal Velocity
Market Segments
Backlog Inflow vs Outflow
Pay Bands

One line

Fully customizable themes

Same query, same chart, same board file. The default is on the left and the rest cycle past it on the right. The only line that differs between any two of them is the third one, and a theme reaches everything from there: the ground, the palette, the type, the grid, the spacing between things.

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.

Themes cascade like everything else: the project, then the theme, then the board, then the chart. A theme is a file like any other and can extend another, so there is no settings screen whose state you cannot diff.

Prose

Markdown, wherever text goes

Titles, notes, annotations and whole text cards take full markdown: headings, lists, tables, links, emphasis, images. A board is allowed to explain itself, so the finding and the chart that supports it live in one file instead of a chart here and a Slack message there.

- type: text
  body: |
    ## Why Q3 turned

    Two things moved: enterprise renewals
    landed early, and **self-serve churn**
    fell for the first time in a year.

    See [the cohort board](./cohorts).

Visualization linting

Visual feedback for the AI

A model writes the board but never sees it. It cannot tell that the labels collided, that the bars packed into a smear, or that the last three columns truncated. So the renderer measures the result and says so, in words: the code, the numbers behind it, and the line to change.

Every render is linted against the same design rules a person would apply squinting at the chart. The agent reads the warning and fixes the one line that caused it, before anyone opens the board.

$ 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.

In your dbt project

charts/ right next to your models/

No new repo, no second home for the definitions. Run dct anywhere inside a dbt project and it finds dbt_project.yml on its own, then connects through the profiles.yml you already keep. No second set of warehouse credentials to store or rotate.

Point a query at a model with ref() and the dependency is real. Boards ship on the same branch as the column rename that would have broken them, in the same pull request, reviewed by the same people.

my_dbt_project/
├── dbt_project.yml
├── profiles.yml
├── models/
│   ├── staging/
│   └── marts/
│       └── fct_users.sql
└── charts/
    ├── users.yml
    ├── pipeline.yml
    └── themes/
        └── newsroom.yml

Reviewed like code

Broken charts fail in CI

Because a board is a file that names the models it reads, the thing that usually breaks a dashboard, someone renaming a column, is something CI can catch on the pull request that renames it. Not in a Slack message on Monday.

Structural, with no credentials

dct init ci writes a GitHub Actions workflow that runs dct validate on every push. It parses the boards and checks them against the schema without a warehouse connection, so it runs on a fork's pull request.

Against your dbt models

Add dbt parse and every board query reaching a model through ref() is checked against the columns that model actually produces. A rename that would break a board fails the check that renames it.

Against the warehouse itself

dct validate --warehouse puts each query to the warehouse by the cheapest means it offers: DESCRIBE on DuckDB, a dry run on BigQuery, EXPLAIN on Postgres, Redshift and Snowflake. No rows scanned, no bill.

On the pull request that renames the column

$ dbt parse && dct validate charts

ERR-DBT-MODEL-COLUMN-MISSING
  charts/documents.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/documents.yml
  charts/growth/acquisition.yml

On dbt charts Cloud the same review happens in the browser: an edit pushes to a branch, Cloud keeps a pull request open against it, and a reviewer sees the board rendered beside the diff before approving the merge.

And when you want it hosted

The language is free. The workspace is the product.

Everything on this page runs on your machine with no account. dbt charts Cloud adds the things a language cannot give you on its own: a live warehouse connection, permissions, scheduled renders, a place to share a board, and conversational analytics over the boards you have already built.

Write your first board in about ten minutes.

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

Terminal uv tool install dbt-charts
Claude / AI Make charts of this with dbt charts. Start with: uv tool install dbt-charts && dct skills intro