Skip to content

Queries

Query expressions are how a block gets data. Almost always you’ll reach for one of two:

  • @expr/query — runs a query and returns a table, automatically scoped to the page’s time range, filters, and parameters. The default for stats, tables, and most charts.
  • @expr/timeseries_query — the same, but returns one row per time bucket. Use it for anything plotted over time; it adds the _time_bucket column and bucketing for you, so a line chart “just works” as the range changes.

The rest — @expr/range_query, @expr/unbound_query, @expr/autocomplete — are escape hatches for when the defaults don’t fit. The exact fields live in the expression reference; this page is about how to drive them.

Two ways to write a query

As a SQL string. The quickest form — write SQL against your view. Reference context with {name:Type} placeholders; they bind from the surrounding parameters and filters without any manual substitution:

{ "@expr/query": "SELECT count() FROM requests WHERE ServiceName = {service_name:String}" }

If you’d rather pass values explicitly than inherit them from context, use the { sql, parameters } form:

{
"@expr/query": {
"sql": "SELECT count() FROM requests WHERE ServiceName = {name:String}",
"parameters": { "name": "checkout" }
}
}

As a structured query. The object form — from, select, where, group_by, … — is easier to build up programmatically and to compose. select is a map of output column to SQL expression:

{
"@expr/query": {
"from": "traces",
"select": { "p95": "quantile(0.95)(Duration)" },
"group_by": "ServiceName"
}
}

The real power of the structured form is that from can itself be another query — nest one to layer a transformation over a base query (or over a shared @expr/use template) without writing a subquery by hand. Write the nested query in its tagged form — the bare tag name (query, range_query, timeseries_query, unbound_query), no @expr/ prefix — and it composes as a sub-query in a single statement:

{
"@expr/query": {
"from": { "query": "SELECT ServiceName, Duration FROM traces" },
"select": { "p95": "quantile(0.95)(Duration)" },
"group_by": "ServiceName"
}
}

The tag still shapes each nested level’s window — range_query requires a range, timeseries_query buckets, unbound_query carries its own bounds — so every level’s macros expand against its own context. Tag every level of a chain: a prefixed @expr/query left in a nested from is evaluated at that level (its full result shipped back and re-injected as data) instead of composed, which for a large intermediate result can overflow the request body — so a deep chain composes end-to-end only when each inner query is tagged.

A prefixed @expr/query in from means something different: it is evaluated to a table first and injected as data, not composed into the SQL. Reach for the tagged form ({ "query": … }) when you want the database to do the work in one query with pushdown; reach for the expression form ({ "@expr/query": … }) when you already have a table to feed in — a context value, inline data, or a query whose full result you want to materialise and then slice locally.

select, group_by, order_by, and limit can each be an @expr/* expression instead of a literal — resolved to its present value and spliced in before the query compiles. In select this reaches all the way down to an alias-map value, so you can keep a stable output name while the column behind it follows a control: { "Metric": { "@expr/get": "metric" } } selects whichever column the metric state names, always aliased to Metric. A chart’s color / y then keeps referencing "Metric" while the data switches underneath — which is what collapses a near-duplicate query (and its surrounding block) per choice into one.

{
"@expr/query": {
"from": "metrics_sum",
"select": [{ "dow": "toDayOfWeek(TimeUnix)" }, { "Metric": { "@expr/get": "metric" } }],
"group_by": "dow"
}
}

To reference the same subquery more than once — say either side of a self-join — name it as a CTE under with instead of inlining it twice. A cte body takes the same shapes as from: raw SQL or a tagged nested query.

{
"@expr/query": {
"with": { "procs": { "cte": { "query": "SELECT pid, ppid, name FROM processes" } } },
"from": "procs",
"as": "parent",
"left_join": { "from": "procs", "as": "child", "on": "parent.pid = child.ppid" },
"select": { "parent_name": "parent.name", "children": "count(child.pid)" },
"group_by": "parent.name"
}
}

A string is SQL; { identifier } is one name

In a value position — a select item or alias-map value, group_by, order_by, a window’s partition_by — a string is spliced as SQL. So parent.name above is a qualification (the name column of the relation aliased parent), and count(child.pid) is a call.

To name a single column instead of writing SQL, use { "identifier": … }. The dialect quotes it, which reaches names SQL text cannot: a flattened attribute key like http.status_code (as text that reads as the status_code column of a relation http), a name holding a space, or one spelling a reserved word.

{
"@expr/query": {
"from": "logs",
"select": [{ "identifier": "http.status_code" }, { "requests": "count()" }],
"group_by": { "identifier": "http.status_code" }
}
}

A group_by dimension the select doesn’t project is added to it for you, so charts and tables can read the dimension back by name. It goes in exactly as you wrote it, so the database names the column — grouping by m.MetricName gives one called MetricName. When you want to fix the name, project the dimension through the alias map yourself; that also suppresses the automatic prepend:

{ "select": { "MetricName": "m.MetricName" }, "group_by": "m.MetricName" }

Because a select item is read as an expression first, identifier and sql are reserved as alias-map output names; to alias a column to either, give the value in { sql } form — { "identifier": { "sql": "ServiceName" } } selects ServiceName AS "identifier".

resolution and transform

Alongside the body, the four query expressions take two options that shape the window a query runs over: resolution decides how finely it’s bucketed, and transform decides which window is queried. resolution is a query-level knob and sits next to from/select (or next to sql); transform is an adjustment to the window, so it lives under a timerange key:

{
"@expr/timeseries_query": {
"from": "spans",
"select": { "requests": "count()" },
"resolution": "low",
"timerange": { "transform": { "shift": "previous_period" } }
}
}

@expr/query, @expr/range_query, and @expr/timeseries_query take exactly these two. @expr/unbound_query takes them too — and since it ignores the surrounding range, its timerange also carries the explicit start/end bounds.

resolution

How finely the window is bucketed: "low" aims for ~12 buckets, "high" for ~64. The bucket size is derived from the active range and snapped to a round interval, so the same resolution gives coarser buckets on a wider window — a chart keeps its shape as you zoom out instead of growing more points.

@expr/timeseries_query defaults to "high"; drop to "low" for a sparkline or a small multiple, where 64 points is just noise. The other three set no resolution unless you ask for one — and without one there’s no _time_bucket to group by.

A third value, "none", states “no bucketing” outright — the same effect as omitting resolution, but explicit, which is what lets an expression resolve to it. @expr/query, @expr/range_query, and @expr/unbound_query all accept it; @expr/timeseries_query rejects it, since a timeseries is defined by its buckets (reach for @expr/query when you want a single whole-window reading). Like the query body’s other options, resolution can itself be an @expr/* expression, so a chart’s granularity can follow a control.

timerange.transform

Under the timerange key, transform moves or grows the window the query runs over. Two variants:

  • { "shift": … } moves the window. "previous_period" moves it back by exactly its own width, whatever the picker currently says; an explicit interval ({ "value": -7, "unit": "days" }) moves it by a fixed amount, negative for backwards.
  • { "expand": … } grows the window symmetrically, by an interval on each side — { "value": 1, "unit": "hours" } buys an hour of lead-in and lead-out.

An interval is { value, unit }, where unit is one of milliseconds, seconds, minutes, hours, days, weeks, months, or years.

The transform applies to the whole query — the time filter, the bucket grid, and gap-filling all follow the transformed window. So a shifted timeseries comes back with its own full set of buckets, aligned back onto the current window so it overlays a chart of the unshifted data.

Comparing against the previous period

{ "timerange": { "transform": { "shift": "previous_period" } } } is what backs a “vs previous period” comparison. Declare it as the background of an @expr/paired_query and each metric comes back holding both readings, which a @block/stat renders as its number and the comparison beneath it. Because the shift is relative to the active window, the comparison stays honest as the user changes the range: an hour compares against the hour before it, a week against the week before it.

{
"@block/context": {
"extend": {
"totals": {
"@expr/paired_query": {
"from": "metrics_sum",
"select": ["Cost"],
"resolution": "low",
"background": { "timerange": { "transform": { "shift": "previous_period" } } }
}
}
},
"@block/stat": {
"title": "Cost",
"format": { "currency": "USD" },
"from": "totals",
"value": "Cost",
"label": "vs previous period"
}
}
}

One entry, four queries: resolution buckets the rows for the sparkline, the whole-window reading for the headline comes back folded into the same column, and the background runs both grains again over the shifted window. This is what the shipped packs use for their KPI rows — declare it once in a @block/context, then let each stat pick its column with just a value.

Comparing two windows by hand

A background covers the comparisons a paired query can run itself. Anything else — a background that differs by a where, or one you want to join other columns against — is an ordinary join whose target carries its own timerange.transform. Every nested query is compiled as a sealed subquery with its own window, so the shifted side’s macros resolve against its window and a rate denominator stays per-window:

{
"@expr/query": {
"from": { "query": { "from": "spans", "select": { "Requests": "count()" } } },
"as": "cur",
"cross_join": {
"from": {
"query": {
"from": "spans",
"select": { "Requests": "count()" },
"timerange": { "transform": { "shift": "previous_period" } }
}
},
"as": "prev"
},
"select": { "Requests": "cur.Requests", "Background": "prev.Requests" }
}
}

@expr/query

The everyday query. Returns a table, bound to the surrounding time range when there is one (and runs unbound when there isn’t). Feed it to a @block/table, a @block/stat’s value, or a non-temporal chart.

{ "@expr/query": { "from": "spans", "select": { "avg_duration": "avg(Duration)" } } }

@expr/timeseries_query

A query that returns one row per time bucket — the right source for a line or area chart over time. It injects _time_bucket into select and group_by and picks a bucket size from the active range, so you don’t have to. That’s resolution defaulting to "high".

{ "@expr/timeseries_query": { "from": "spans", "select": { "requests": "count()" } } }

@expr/paired_query

Runs one query body twice — as a foreground and a background — and returns both in one table, so a comparison is one entry instead of two queries wired together by hand. Structured-only: every selected metric needs a declared output name, since that name is what the background pairs against.

select is an ordinary select clause, so a bare entry names itself and an aliased one names its key — ["Cost", "ErrorRate", { "AvgDuration": "avg(Duration)" }] declares Cost, ErrorRate and AvgDuration. Selecting a view scalar is just its name. A bare expression names itself too ("avg(Duration)" comes back under that literal text), so alias it unless you mean to reference it that way.

{
"@expr/paired_query": {
"from": "metrics_sum",
"select": { "Cost": "sum(Value)" },
"resolution": "none",
"background": { "timerange": { "transform": { "shift": "previous_period" } } }
}
}

background says only how the background differs:

  • timerange — the same metric over a different window (“vs previous period”).
  • select — a different metric over the same window, keyed by the foreground alias it stands against: { "select": { "Errors": "count()" } } makes count() the background of Errors. It is also how you compare two slices of a dimension — write the pair as conditional aggregates, countIf(Version = 'canary') against countIf(Version = 'stable').

Each metric keeps its own name. Cost stays Cost; when a background is declared it holds a pair of foreground and background values instead of a single number. The column’s own shape is what tells a block to unpack it — nothing is renamed, there is no suffix to memorise, and a Tuple(foreground …, background …) you select yourself is a pair on the same terms. With no background declared, the result is exactly what a plain query would have returned.

resolution decides the shape and is required. "none" returns the metrics as one scalar row. "low" or "high" bucket them and fold each metric’s whole-window reading into the same column, so Cost becomes a { window, series } struct — window the headline (constant across rows), series the per-bucket value, each of those a foreground/background pair when a background is declared. That one column feeds a stat’s headline number and its sparkline, which is why a stat over a paired query authors only value.

The whole-window reading is always its own unbucketed query, never a rollup of the buckets: a bucketed query measures each row over one bucket, so a whole-window number taken from it would divide a rate by the wrong amount of time, and a percentile of percentiles is not a percentile.

The window reading is always its own unbucketed query, never a rollup of the buckets: a bucketed query measures each row over one bucket, so a whole-window number taken from it would divide a rate by the wrong amount of time, and a percentile of percentiles is not a percentile.

Likewise, a background over a different window is a second query, while a background over the same window is only an extra column — so comparing two metrics costs nothing extra.

Because a paired column holds two values rather than one, a block has to know to unpack it — @block/stat does. Handed to a block that doesn’t, a paired column won’t render meaningfully, and a result containing one can’t be used as the from: of another query. Declare no background and none of that applies: the result is an ordinary table.

@expr/range_query (escape hatch)

Like @expr/query but it requires a time range in scope and throws if there isn’t one. Reach for it only when running a query without a window would be a bug you’d rather catch loudly.

{ "@expr/range_query": { "from": "spans", "select": { "avg_duration": "avg(Duration)" } } }

@expr/unbound_query (escape hatch)

Ignores the surrounding time range entirely; you pass an explicit timerange with start/end bounds (or none). Use it for a fixed comparison window — “last 30 days regardless of what the page shows”.

Each bound is a TimeBound, the same grammar the time picker uses: date math ("now-30d", "now-1d/d"), an ISO timestamp ("2026-05-30T00:00:00Z"), or a { value, unit } interval — plus a raw unix-ms number for a truly fixed instant. end is optional and defaults to now, so { "start": "now-30d" } is a rolling 30-day window. Prefer date math to a frozen number: "now-30d" means the same thing whenever the query runs, which is what makes the query portable. Both bounds can also be @expr/* expressions. resolution and timerange.transform work here too, against that explicit window (transform sits in the same timerange object as the bounds).

{
"@expr/unbound_query": {
"from": "metrics_sum",
"select": ["Cost"],
"timerange": { "start": "now-30d" }
}
}

@expr/autocomplete (escape hatch)

Not a data query — it resolves to a list of suggestions for a field (column names, or matching values for a search prefix). It’s what backs a @block/field_filter; you rarely author it directly. Suggestions are drawn from the view tables that define the field; pass table to pin one explicitly (the filter blocks forward their own table prop for you).

{ "@expr/autocomplete": { "field": "StatusCode", "value": { "@expr/get_context": "input" } } }