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.sql → finance.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.