# Dashboards (.dash)

> Build DuckLocal dashboards as .dash files of DuckDB SQL: plots, cross-filters, comments an AI agent can answer, and validation with ducklocal check.

Source: https://ducklocal.app/docs/dashboards

**[中文](zh/dashboards.md)**

A `.dash` file describes a dashboard as data: **source** blocks name data files, **query** blocks hold SQL, **plot** blocks draw the results, and **filter** blocks let a click on one plot narrow the others. There is no code to write. Both people and AI agents can create the file, review it in a diff, and validate it before anyone opens it.

A `.dash` file draws standard charts, tables, numbers, and maps from saved queries. Its plain-text format also makes it useful for working with an AI agent: the agent writes or edits the file, `ducklocal check` verifies that it runs, and you [comment on the plots](#review-with-comments) to request changes.

## A complete example

This dashboard queries the [tutorial's sample file](tutorial.md#sample-file). Download [sales.dash](https://ducklocal.app/docs/samples/sales.dash) into the same directory as `sales.csv`, then open it.

```hcl
source "sales" {
  path = "sales.csv"
}

query "totals" {
  sql = <<SQL
    SELECT round(sum(revenue), 2) AS revenue, sum(orders) AS orders,
           count(DISTINCT region) AS regions
    FROM sales
    WHERE $channel
  SQL
}

query "by_channel" {
  sql = <<SQL
    SELECT channel, round(sum(revenue), 2) AS revenue
    FROM sales
    GROUP BY channel
    ORDER BY revenue DESC
  SQL
}

query "daily" {
  sql = <<SQL
    SELECT date, region, round(sum(revenue), 2) AS revenue
    FROM sales
    WHERE $channel
    GROUP BY date, region
    ORDER BY date
  SQL
}

plot "revenue" {
  type  = "card"
  query = query.totals
  value = revenue
  title = "Revenue"
  width = 4
}

plot "orders" {
  type  = "card"
  query = query.totals
  value = orders
  title = "Orders"
  width = 4
}

plot "regions" {
  type  = "card"
  query = query.totals
  value = regions
  title = "Regions"
  width = 4
}

plot "revenue_by_channel" {
  type  = "bar"
  query = query.by_channel
  x     = channel
  y     = revenue
  title = "Revenue by channel"
  width = 5
}

plot "daily_revenue" {
  type   = "line"
  query  = query.daily
  x      = date
  y      = revenue
  series = region
  title  = "Daily revenue, per region"
  width  = 7
}

plot "daily_table" {
  type  = "table"
  query = query.daily
  title = "The numbers behind it"
}

filter "channel" {
  plot = plot.revenue_by_channel
}
```

It opens as three numbers across the top, the bar chart and the line chart
side by side, and the table under them. Each query runs once, however many
plots draw from it: `totals` feeds all three cards, and `daily` feeds both the
line chart and the table. Click a bar in **Revenue by channel** and the cards,
the line chart and the table narrow to that channel; the bar chart itself
stays whole, so you can click another.

## Open a dashboard

Any of these opens a `.dash` file as a dashboard tab, next to your queries:

- the **+** at the end of the tab strip, then **Open dashboard**;
- dragging the file onto the window;
- naming it on the command line: `ducklocal sales.dash`;
- from a script or an agent, into the window that is already running:
  `ducklocal open sales.dash` (see the [CLI guide](cli.md#show-work-in-the-running-window)).

Plots are laid out on a 12-column grid: each spans its [`width`](#layout),
plots fill a row left to right, and a plot that no longer fits starts the next
row. Drag the dividers between rows to resize them; a dashboard taller than the
tab scrolls. A plot that cannot draw — its query failed, or a column is
missing — shows the reason in its own panel, and the rest of the dashboard
still draws. Open dashboards come back on the next launch, and recently opened
ones are listed under **Dashboards** in the sidebar.

Hover a plot to reveal its controls in the top-right corner:

- **SQL** turns the plot over to the SQL its query last ran — its sources and
  the current filter picks filled in, so it runs as it stands — with **Copy**
  and **Open in a query tab**. **Chart** turns it back.
- The speech bubble opens the plot's [comments](#review-with-comments).
- **Enlarge this plot** expands it to fill the dashboard tab. Use **Back to the dashboard (Esc)** or press **Esc** to return to the grid.

The SQL view highlights syntax. Right-click a dashboard table cell to copy the cell, its tab-separated row, or its column in the displayed order.

Click a `table` plot's column header to sort it: descending, ascending, then
back to the query's order. Numbers sort as numbers and `NULL` sorts last; a
row picked for a [filter](#filters) stays picked through a sort.

The toolbar's **View spec** switches the tab to an editor for the file, with
syntax highlighting (the SQL in heredocs is coloured like the SQL editor's),
completion and inline diagnostics. **Save** or **⌘S** writes it back and
redraws. If the saved file fails validation, the tab keeps the last working
dashboard and shows the error above it. The same happens when another program
changes the file. **Back to dashboard** returns to the plots. **Reload**
re-reads the file and runs every query again.

## Review with comments {#review-with-comments}

Comments let you review a dashboard the way you would a document, and hand
the changes to an AI agent. The product thinking behind this workflow is
explored in [What Claude Dashboards taught me about DuckLocal](https://ducklocal.app/blog/claude-dashboards-ducklocal/).

**In the window**, hover a plot and click the speech bubble. A card opens over
the dashboard, quoting the plot's title, with the threads already on it and a
box to write in. Write what should change — *"rank these, biggest channel
first"* — and press **Comment** (or **⌘/Ctrl+Enter**). Each thread has
**Reply** and **Resolve**; a resolved thread stays, dimmed, until someone
replies to it. A plot with open threads keeps a bubble with their count in
view, so you can see at a glance what is waiting.

**The agent** reads and answers the threads with
[`ducklocal comments`](cli.md#review-comments-on-a-dashboard), no window
needed:

```bash
ducklocal comments sales.dash
```

```json
{
  "file": "sales.dash",
  "comments_file": "sales.dash.comments.json",
  "open": 1,
  "threads": [
    {
      "id": "c1",
      "plot": "revenue_by_channel",
      "plot_title": "Revenue by channel",
      "plot_query": "by_channel",
      "plot_lines": [68, 75],
      "plot_missing": false,
      "resolved": false,
      "comments": [
        { "author": "user", "text": "Rank these, biggest channel first", "at": "2026-10-10T14:03:00+08:00" }
      ]
    }
  ]
}
```

Each thread names its plot, the plot's query, and the lines of its `plot`
block in the file, so the agent knows what to edit. It changes the spec, runs
`ducklocal check`, and answers:

```bash
ducklocal comments sales.dash --reply c1 --text "Sorted by revenue, descending" --resolve
```

The window watches both files. Within a second the plot redraws from the
edited spec, and the agent's reply appears in its thread, marked **Agent**.

The threads are kept beside the dashboard, in `NAME.dash.comments.json`, so
they travel with it like its `source` files do. They are working notes, not
part of the dashboard: leave them out of version control, or delete the file
once the review is done.

The [official agent skill](cli.md#install-the-official-skill) teaches this
loop, so an agent that has it only needs to be told *"address the comments on
sales.dash"*.

::: info New in 0.4.0
An agent can [propose a change](agent-workflows.md#propose) rather than editing the file. The tab then offers **Review**, with the diff and checks, and only you can apply it.
:::

### See an agent at work {#agent-presence}

While an agent runs `check`, `comments` or `presence` for this open dashboard, its name appears in the corner, with its maker's mark — Claude, OpenAI, Gemini, Cursor, DeepSeek — or its initial. Click the name to see the session log. When it edits the file, changed and added plots stay marked (**Claude changed this**, **Claude added this**) until you have had them in view for a moment; a removed plot leaves a notice you can dismiss.

An agent can announce its next edit with `ducklocal presence sales.dash --doing editing --plot revenue_by_channel`. That plot gets a ring; a plot name that does not exist yet gets a placeholder below the dashboard. The session ends after 30 seconds without activity, or an explicit `--doing idle`. A finished agent stays in the corner while a thread it answered waits for your reply, with the count on its avatar. Comments record which agent wrote them and show its mark. The [CLI reference](cli.md#agent-presence) covers the options. These indicators show local activity; they do not send work to an agent.

## Blocks

A file is a sequence of blocks, each a type, a quoted name and a body:

```hcl
source "name" { ... }
query "name" { ... }
plot "name" { ... }
filter "name" { ... }
```

Names must be unique within their kind: two queries cannot share a name, and
neither can two plots — but a query and a plot may.

### source

| Attribute | Required | Value |
| --- | --- | --- |
| `path` | yes | A `.csv`, `.tsv`, `.txt`, `.parquet`, `.json`, `.jsonl` or `.ndjson` file, or a glob of them (`exports/orders-*.csv`) read as one relation |

A source gives data files a name the queries read: with `source "sales"`, a
query says `FROM sales`. The path is **relative to the `.dash` file**, not to
wherever DuckLocal was started, so a dashboard and its data can move together;
`~` and absolute paths work too.

A source never touches the connection: it becomes a `WITH sales AS (…)` in
front of each query that names it, so nothing appears in the sidebar, nothing
outlives the tab, and a source shadows a table of the same name for this
dashboard's queries only. The name must be a plain identifier (letters, digits
and `_`). A workbook cannot be a source; open it in DuckLocal and query the
table it becomes.

### query

| Attribute | Required | Value |
| --- | --- | --- |
| `sql` | yes | One read-only SQL statement, as a [heredoc](#heredocs) or a string |

A query block holds `sql` and nothing else. It may read `$name` for a
[filter](#filters).

### plot

| Attribute | For | Value |
| --- | --- | --- |
| `type` | all, required | `"line"`, `"bar"`, `"stacked_bar"`, `"area"`, `"scatter"`, `"pie"`, `"heatmap"`, `"histogram"`, `"map"`, `"table"`, `"card"` or `"text"` — a quoted string |
| `query` | all except `text`, required | A reference to a query block: `query.by_channel` |
| `x` | charts, required | The column on the horizontal axis; a `pie`'s slices; the number a `histogram` bins. A `table` shows every column and needs none |
| `y` | charts, required | The column to plot; must be numeric. A `pie`'s slice sizes. A `histogram` takes none |
| `series` | `line`, `bar`, `stacked_bar`, `area`, `scatter`, `heatmap` | A column whose values split the rows into one series each; a `heatmap`'s rows |
| `title` | all | A heading for the plot, as a quoted string; a card's label |
| `width` | all | How many of the 12 grid columns the plot spans, `1`–`12`. Default 12, or 3 for a card |
| `value` | `card` | The column to show, from the first row. Default: the first column |
| `note` | `card` | Text under the value: a quoted string, or a bare column name to read from the first row |
| `tone` | `card` | `"accent"`, `"good"`, `"warning"` or `"critical"`; colours the top rule and value |
| `format` | charts and `card` | What the numbers are: see [Number formats and change](#number-formats-and-change). A `map` or `table` takes none |
| `delta` | `card` | A numeric column holding the change, shown under the value |
| `delta_format` | `card` | A format for the change. Default: the card's |
| `down_is_good` | `card` | `true` turns a fall green and a rise red, for costs, latency or churn |
| `description` | all, **0.4.0+** | What the plot shows and how to read it; appears on hovering an ⓘ beside the title |
| `text` | `text` | Body text as a string or heredoc; supply `title`, `text`, or both, with no `query` |
| `lat`, `lng` | `map` | Latitude and longitude columns, in degrees. Either may be left out when the numeric column's name indicates the coordinate — `lat`, `latitude`, `geo_lat`; `lng`, `lon`, `longitude` |
| `color` | `map` | A column to colour the points by; left out, one is picked when a text column has only a few values |
| `size` | `map` | A numeric column to size the points by |
| `size_scale` | `map` | `"sqrt"` (the default) or `"log"`, for how `size` scales |
| `tooltip` | `map` | A list of the columns a point's tooltip shows: `[name, passengers]` |

Column attributes name columns of the query's result, matched without regard to
case. Write a plain name bare (`x = date`); quote one that is not a plain
identifier (`y = "revenue (EUR)"`). An attribute that the plot's type has no
use for — `lat` on a line, `x` on a map, `series` on a pie — is a mistake, not
something quietly ignored.

### filter

| Attribute | Required | Value |
| --- | --- | --- |
| `plot` | yes | The plot a click picks a value on: `plot.revenue_by_channel`. A `bar`, a `stacked_bar` or a `table` |
| `column` | for a `table` | The column whose value is picked. For a `bar` or `stacked_bar`, its `x` when left out |

See [Filters](#filters) for what a filter does.

## Plot types

| Type | Draws |
| --- | --- |
| `line` | A line per series, unfilled |
| `area` | A filled area per series |
| `bar` | A bar per row, one colour per series. With `series`, the series' bars side by side in each group. A single series labels each bar with its value |
| `stacked_bar` | One bar per `x`, its `series` stacked: positive parts up from zero, negative ones down, with the total on top and in the tooltip |
| `heatmap` | `x` across and `series` down, each cell coloured by `y` on one blue, darker for more (lighter in dark mode). A pair the query doesn't return stays empty |
| `histogram` | The rows counted in each range of a numeric `x`, in round-width bins. Let its query return the rows themselves, not a grouping |
| `scatter` | A dot per point, unconnected, one colour per series |
| `pie` | One slice per `x` value, sized by `y`, largest first; past 8 slices the smallest fold into "other". Drawn as a ring with the total in the middle; click a key entry to pick a slice out, and again to restore it |
| `text` | A title, explanatory text, or both; no query runs |
| `map` | A point per row, at its `lat` and `lng`; see [Maps](#maps) |
| `table` | The query's result as a grid, columns sized to their content; click a header to sort |
| `card` | One number, large, with the `title` as its label. Grouped by thousands, a long fraction rounded (`11439704.020000005` → `11,439,704.02`), and shrunk to fit the card rather than cut off |

Hover a chart for its values, formatted the way the axis shows them (`4.4M`,
not `4400000`).

A bar chart labels **every** category. When the labels do not fit under their
bars, the chart lies on its side, with the labels in a column on the left. Only
a time axis thins its labels, keeping as many as fit — always the first and the
last — and a bar over a `DATE` column fills in the days the query returned
nothing for as empty bands, so a quiet stretch reads as a gap rather than
vanishing. Line, area and scatter axes thin their labels the same way.

The x axis is categorical for every chart type: values are spaced evenly in
the order the query returns them, not by their distance from one another. A
`scatter` of two numeric columns is therefore not a true x–y plot.

Rows are plotted in the order the query returns them, so end a query that
feeds a chart with `ORDER BY`.

::: info New in 0.4.0
A dashboard `line` or `scatter` over times is placed by time, so uneven gaps look uneven, and it draws in time order whatever the `ORDER BY`. A row with a NULL time is left off, with a note. A NULL reading the query returned breaks the line. An `area` stays evenly spaced and says so. A series keeps its colour by name, and every chart says when its query was cut short.
:::

A card shows its query's first row. When the query returns more than one row,
the card says so under the number; when it returns none, it shows `—`.

### Cards and text panels

Cards can explain a number with `note` and give it a status colour with `tone`. Cards in the same row share a value font size. Text panels add headings or context without running SQL:

```hcl
plot "revenue_summary" {
  type  = "card"
  query = query.totals
  value = revenue
  title = "Revenue"
  note  = "Current channel selection"
  tone  = "accent"
  width = 4
}

plot "reading_guide" {
  type  = "text"
  title = "How to read this dashboard"
  text  = "Click a channel to filter the daily chart and revenue cards."
  width = 8
}
```

A text panel accepts only `type`, `title`, `text` and `width`. Use `text = <<TEXT` with a closing `TEXT` line for a longer explanation. Charts, pies and maps use eight distinct hues, with shades adapted to the current theme and a key for multiple series.

### Number formats and change {#number-formats-and-change}

`format` shapes a card's value, the value axis, bar labels and tooltips:

| Value | Reads |
| --- | --- |
| `"num0"`, `"num1"`, `"num2"` | A number with that many decimal places |
| `"pct"` (the same as `"pct1"`), `"pct0"`, `"pct2"` | A share: 0.421 reads 42.1%, so return the fraction, not ×100 |
| `"usd"`, `"eur"`, `"gbp"`, `"cny"` | Money, with two decimal places |
| `"usd0"`, `"eur0"`, `"gbp0"`, `"cny0"`, `"jpy"` | Money, in whole units |

A card shows a change with `delta`, under the value as `▲ 12.3%` or `▼ $125`. It is green when good and red when not, and the `note` follows it to say against what. Compute the change in the card's query:

```hcl
plot "revenue_total" {
  type         = "card"
  query        = query.totals
  value        = revenue
  format       = "usd0"
  delta        = growth
  delta_format = "pct1"
  note         = "vs last week"
}
```

### Layout

`width` is how many of the dashboard's twelve columns a plot spans. Plots fill
a row from the left in the order they are written and wrap when the next one
does not fit, so four cards (`3` each) make a row of numbers, and two
`width = 6` charts sit side by side beneath them. A row short of twelve leaves
the rest empty. A row of nothing but cards starts at a short height, a row of
nothing but tables starts as tall as its longest table's rows, up to a chart's
height. A row of text panels fits its text; other rows start at chart height. Row resize handles appear on hover.

### Maps

```hcl
plot "stations" {
  type       = "map"
  query      = query.stations
  lat        = geo_lat
  lng        = geo_lng
  color      = line
  size       = passengers
  size_scale = "log"
  tooltip    = [name, passengers]
}
```

A map plots each row as a point, on a Web Mercator projection fitted to the
points, over a latitude/longitude grid labelled in degrees.

- **`color`**: up to five values each get a colour; with more, the four most
  common do and the rest share an "Other" colour. A key reads them back. Left
  out, the points are coloured by a text column with only a few distinct
  values, when the result has one.
- **`size`**: a point's *area* follows the value, so twice the value is twice
  the ink. `size_scale = "log"` suits values spanning orders of magnitude. A
  key in the corner reads sizes back.
- **`tooltip`**: without it, a point's tooltip lists the size column and the
  other numbers, then the coordinates.

Under the points, a map can draw an **OpenStreetMap** base map. It is off until
you turn it on — **View → Online base map (OpenStreetMap)** — because loading tiles needs the network. What it sends is
which map tiles it wants, which reveals roughly the area being looked at, never
a row of your data. Tiles are cached on disk for a week (see
[Settings and app data](settings-and-data.md#where-things-are-stored)); offline
or off, the map draws over its latitude/longitude grid alone. The same switch covers the map
in the query results' **Chart** tab.

## Filters

A filter makes a plot clickable, and lets the value picked there narrow other
queries:

```hcl
filter "channel" {
  plot = plot.revenue_by_channel     # a click on a bar picks its channel
}

filter "day" {
  plot   = plot.days                 # a table: a selected row picks …
  column = day                       # … the value of this column
}

query "daily" {
  sql = "SELECT date, sum(orders) AS orders FROM sales WHERE $channel AND $day GROUP BY 1"
}
```

A query reads a filter as **`$name`, a condition**: write it where a
condition goes. While nothing is picked it is `TRUE`, so the dashboard opens
exactly as it would without the filter. Once a value is picked it becomes
`("channel" = 'Web')` — the column named as the filter's `column`, compared
with the value picked; a cell displayed as `NULL` matches SQL NULL or the literal text `NULL`. Several filters
combine however the query combines them.

- **On a bar chart**, click a bar to pick it: it keeps its colour while the
  others fade. Click it again to clear the pick.
- **On a table**, select a row, by clicking or with the arrow keys.
- The picks in force show as chips above the plots; each chip's ✕ clears its
  filter, and **Clear all** clears every pick.
- **The picking plot is never narrowed by its own filter.** Its query reads
  `$channel` as `TRUE` even while a channel is picked — otherwise one click
  would reduce the bar chart to the bar just clicked, with nothing left to
  click next. This is cross-filtering, as in Mosaic or crossfilter.
- **Only what changed re-runs.** A pick re-runs the queries whose SQL it
  changes; a query that reads no filter keeps its result.

A filter holds one value at a time. Pickable plots include a click-to-filter hint below the title. Series retain their colours as filtering changes which groups remain. A query using `$channel` must expose the filter's column where that predicate is used; `ducklocal check` now binds a picked-value variant as well, so a missing column is caught before the first click.

::: info New in 0.4.0
Picking a cell displayed as `NULL` selects only SQL NULL, and the text `'NULL'` selects only itself. Pickable plots no longer show a click-to-filter hint below the title.
:::

The picked value goes into the SQL as a quoted literal, and DuckDB converts it
to the column's type when comparing, so dates and numbers work as well as
text. Picks last while the tab is open and survive a reload, as long as the
filter is still in the file; they are not remembered between launches.

## Values

| Form | Example | Used for |
| --- | --- | --- |
| String | `"Revenue by channel"` | `type`, `title`, `path`, `size_scale`, `tone`, literal `note`, `text`, quoted column names, short `sql` |
| Heredoc | `<<SQL` … `SQL` | `sql`, `text` |
| Reference | `query.daily`, `plot.revenue_by_channel` | a plot's `query`, a filter's `plot` |
| Bare name | `channel` | column attributes |
| Number | `6` | `width` |
| List | `[name, "Unique IPs"]` | `tooltip` |

A string ends at its line and understands `\n`, `\t`, `\"` and `\\`.

`#` and `//` start a comment that runs to the end of the line. Attributes need
no separator; line breaks are just whitespace.

That is the whole language: no variables, no functions, no conditionals, and
no interpolation beyond a filter's `$name`.

### Heredocs

A heredoc holds text across lines — in practice, SQL:

```hcl
  sql = <<SQL
    SELECT 1
  SQL
```

- The delimiter after `<<` is any word (`SQL` by convention), and nothing may
  follow it on its line.
- The heredoc ends at the first line that is exactly the delimiter, give or
  take surrounding spaces.
- The closing line's indentation is removed from every line of the text, so
  the SQL can sit at the block's indentation without carrying it.

## SQL rules

A dashboard runs its queries whenever it is opened, reloaded or saved, and when
DuckLocal starts with the tab restored — so a query may only **read**:

- **Allowed:** `SELECT`, `WITH …`, `FROM`-first queries, `VALUES`, `SHOW`,
  `DESCRIBE`, `SUMMARIZE`, `PIVOT` and `UNPIVOT`.
- **Refused:** anything that could change data or files — `CREATE`, `INSERT`,
  `UPDATE`, `DELETE`, `DROP`, `COPY`, `ATTACH`, `INSTALL`, `LOAD`, and so on.
  Opening a `.dash` file someone sent you cannot change your data.
- **One statement per query.**
- **A `PIVOT` inside another query names its values.** A query that starts
  with `PIVOT` may leave the values for DuckDB to find
  (`PIVOT orders ON channel USING sum(amount)`). One nested inside a `SELECT`
  or a `WITH` needs an explicit list: `ON channel IN ('Web', 'Store')`.
- **`$name` must name a filter block.** The read-only check judges the SQL
  with every `$name` as `TRUE`. A `$` inside a string, a quoted identifier, a
  comment or a `$$ … $$` string is left alone.

Queries run on the window's own connection, so they see everything the SQL
editor does: opened files, attached databases, and connection-level state such
as `TEMP` tables.

### Paths in SQL

Since v0.2.2, relative file paths in dashboard SQL are searched from the `.dash` file's folder too. For example, `FROM 'sales.csv'` can read the CSV beside the dashboard when it is opened from another directory. This applies in the window and in `ducklocal check`.

DuckDB still checks the working directory first for a single file named directly in SQL: a same-named file there can take precedence. A [source](#source) block resolves its path explicitly against the dashboard folder and avoids that ambiguity. Absolute paths and registered views remain available.

## Limits

| Limit | What happens |
| --- | --- |
| 12 series per plot | Further series are left out, and a notice says how many there were |
| 1,500 points per series | Points are bucket-averaged down to fit |
| 200 bars | Further bars are left out |
| 8 pie slices | The smallest fold into one "other" slice |
| 200 heatmap columns, 12 rows | Further columns and rows are left out |
| 5–40 histogram bins | About √rows of them, each 1, 2, 2.5 or 5 × a power of ten wide |
| 20,000 map points | Further points are left out, and a notice says so |

## Validate a dashboard

`ducklocal check` validates a file without opening a window — the step to run
before handing a dashboard to anyone:

```bash
ducklocal check sales.dash
ducklocal check sales.dash --database warehouse.duckdb
ducklocal check sales.dash --summary
```

First the static checks: the syntax, the names and references, the required
attributes, every `$name` against the filter blocks, and each query's SQL
through DuckDB's own parser. Then **every query runs**, read-only, with its
sources and with nothing picked, and each plot's columns are checked against
the columns its query really returns — including that `y`, a map's
coordinates and its `size` are numeric, and that each filter's `column` is one
its plot's query returns. Running matters: a cast that fails or a glob that
matches nothing only shows once rows are read.

- **Without `--database`** the queries run in memory, which is all a
  dashboard over its own `source` files needs. A query that reads a table no
  source defines is listed under `unresolved` rather than failed, and its
  plots' columns go unchecked — it is probably meant for a database.
- **With `--database PATH`** they run against that database, opened
  read-only. Pass it whenever the queries read a database's tables.

Each query that uses a filter is also bound with a picked value, without executing that variant. This catches predicates that refer to a column unavailable in that query. Blocks with mistakes do not prevent sound blocks from being checked: diagnostics include both spec errors and query failures from the same run.

Success prints a JSON summary of the sources, queries (with their result
columns and how many `rows` each returned), filters and plots, plus `empty`:
the queries that ran and returned no rows. That is not an error, but usually
a regex, filter or join that matched nothing, so look at each before handing
the dashboard over. `--summary` prints one JSON line of counts and the names
of the unresolved and empty queries. A mistake exits with
status **2** for spec diagnostics and lists every problem at once, one `file:line: message` each. SQL-only, database or file failures exit with **1**. The
[CLI guide](cli.md#check-a-dashboard-spec) has the details.

::: info New in 0.4.0
`ducklocal check FILE --restricted --allow-read PATH` checks a dashboard you didn't write under limits. See [Limit SQL you did not write](agent-workflows.md#restricted).
:::

### Common messages

| Message | Fix |
| --- | --- |
| `type is a string in quotes` | Write `type = "line"`, not `type = line` |
| `Unknown plot type: "donut"; one of line, bar, area, scatter, pie, map, table, card, text` | Use one of the listed types |
| `plot block "p" requires y` | Every chart type needs a `y`; `table`, `map`, `card` and `text` do not |
| `plot "p" draws query "q", which the file does not define` | Add the query block, or fix the name in `query = query.…` |
| `query names a query block: query = query.some_name` | The `query` attribute takes a reference, not a string |
| `plot "p" draws y = "channel" (VARCHAR), which is not numeric` | Point `y` at a number column, or use `type = "table"` |
| `A map plot places points by lat and lng; it takes no x` | A map has no axes: give `lat` and `lng`, or leave them to the column names |
| `width is how many of the dashboard's 12 columns the plot spans` | Write a whole number from 1 to 12 |
| `query "q" reads $chanel, but no filter block is named "chanel"` | Fix the name, or add the `filter` block |
| `filter "f" picks on plot "p", a line; a value is picked on a bar or table plot` | Point the filter at a bar chart or a table |
| `a dashboard query must be a single read-only statement` | Move writes to the SQL editor, or name a nested `PIVOT`'s values with `IN`; see [SQL rules](#sql-rules) |
| `Unknown block: "card"; a card is a plot: plot "x" { type = "card" … }` | Every panel is a plot block, cards and text panels too |
| `"minutes" is a SQL keyword, so as a column alias it needs AS` | Write `count(*) AS minutes`, or quote the alias |
| `Unterminated heredoc: no line is exactly SQL` | Add the closing delimiter on a line of its own |

## Edit in your own editor

`ducklocal lsp` is a language server for `.dash` files: diagnostics as you
type, completion for block types, attributes, plot types and the names after
`query.` and `plot.`, hover documentation, and go-to-definition from a
`query.name` or `plot.name` reference to its block. Setup for Neovim and
VS Code is in the [CLI guide](cli.md#edit-a-dashboard-spec-with-lsp).
