Queries

SQL lives either in the shared query library under queries/ (the recommended default) or, for a quick one-off, inline in a :::query block on a page.

Tip

Prefer the queries/ library. Defining each query once in its own file — rather than inline on a page — keeps SQL out of your prose, lets you reuse and compose queries across pages, and makes them easy to find and edit. Reach for an inline :::query only for a throwaway query used on exactly one page.

The shared query library #

Drop a .sql (or .dax) file in queries/; reference it by name from any page. The file name becomes the query name (finance/mrr.sqlfinance.mrr). So queries/downloads_by_month.sql:

-- queries/downloads_by_month.sql
SELECT month, SUM(downloads) AS downloads
FROM downloads GROUP BY month ORDER BY month

is referenced from any page by that name — this page's chart uses it:

<LineChart data={downloads_by_month} x="month" y="downloads" />

Per-query options (connector, cache_ttl, live, …) go in the file's YAML frontmatter; the default connector is main.

Need more than SQL?

A query in queries/ can also be a .py file — a function returning a table (pandas / Polars / Arrow / list-of-dicts) — for forecasts, ML scoring, cross-connector joins, or anything awkward in SQL. See Python queries.

Composition #

Library queries can compose with a dbt-style ref('other'), compiled into inline CTEs at load time — so you build complex queries from small, named building blocks instead of repeating subqueries.

Inline queries #

For a one-off used on a single page, you can still define the SQL inline:

:::query name=downloads_by_month connector=main
SELECT month, SUM(downloads) AS downloads
FROM downloads GROUP BY month ORDER BY month
:::

<LineChart data={downloads_by_month} x="month" y="downloads" />

Either way, the SQL is collected and never executed server-side during page render — the page ships instantly with empty datasets, and the browser fetches each query's data from the data API. This keeps first paint fast and the render path pure.

Parameters & injection safety #

${param} placeholders are filled by a single context-aware substitution function. It is the one and only injection defense — there is no bind-parameter mechanism:

  • '${x}' (inside single quotes) → the value is '-escaped in place.
  • IN (${x}) → a multi-select expands to a quoted, per-item list, capped.
  • a bare ${x} → wrapped in quotes.

Every value becomes a quoted string literal, so ${id} with value 1 OR 1=1 is inert.

Warning

Don't try to interpolate identifiers (table/column names) through ${...} — the substitution always produces a string literal. Parameterize values, not SQL structure.

Caching results #

Set cache_ttl to cache a query's result so repeat requests (and repeat page loads) don't re-run it until the TTL expires. On a library query, put it in the file's frontmatter:

-- queries/daily_metrics.sql
---
cache_ttl: 300
---
SELECT day, SUM(downloads) AS downloads FROM downloads GROUP BY day

On an inline query it's an attribute instead (:::query name=… cache_ttl=300). With no cache_ttl, results aren't cached — each request runs the query fresh. (For data that should repaint as it changes, see Real-time data instead.)

SQL stays server-side #

Query SQL is never written into the page source — it stays on the server. The page references each query by name, and the data API (/_dashdown/api/data/<query>) looks the SQL back up server-side and runs it. View source on a rendered page and you won't find the SELECT behind a chart.

No configuration is needed; this is always the case.

Generated