Skip to content

Parameterized metrics

revenue_1m_avg, revenue_3m_avg, revenue_6m_avg are one metric asked three ways. Modeled one per width they multiply: every new width is another metric to define, review, and keep in step with its siblings — and every dashboard that shows "the moving average" has to say which one it means.

A parameterized metric declares the width itself as a typed, query-time parameter. One moving_avg metric, and the query says how wide:

$ dosi --model model.yaml query --metrics amount_total,moving_avg \
    --group-by metric_time:month --where "sales.region = 'east'" \
    --order metric_time__month --param n=1,3,6 --execute --db sales.duckdb
metric_time__month   amount_total  moving_avg__n_1  moving_avg__n_3  moving_avg__n_6
2024-11-01 00:00:00  10            10               10               10
2024-12-01 00:00:00  20            20               15               15
2025-01-01 00:00:00  30            30               20               20
2025-02-01 00:00:00  40            40               30               25
2025-03-01 00:00:00  50            50               40               30
2025-04-01 00:00:00  60            60               50               35
6 rows

A parameter fills a typed hole in the metric's declaration — never SQL text. It is declared with a type and a domain, so the model is valid for every value the parameter is allowed to take, and a binding can neither step outside that domain nor inject anything into the compiled SQL. The domain is where governance lives: it states which definitions this metric is permitted to take.

This page is the reference for the feature — what can be parameterized, how to declare and bind it, and where the boundaries are. The wire-level schema is in datus-extensions.md.


What can be parameterized

A parameter can fill exactly these six holes, and nothing else:

Slot What it controls Declared on Type
rolling.periods the width of a rolling window window.periods under type: rolling int, domain ≥ 1
frame.preceding how many buckets a general frame looks back window.frame.preceding int, domain ≥ 0
offset.count how many periods back a comparison reaches window.offset.count (object form) int, domain ≥ 1
rank.buckets the bucket count of an ntile window.rank.buckets int, domain ≥ 1
value.n the position an nth_value reads window.value.n int, domain ≥ 1
filter.literal a literal in a derived filter metric's predicate the where of a derive of type: filter int, float or string

The five window slots are counts, so they take integers. A filter.literal hole takes any of the three types (region = :r, amount > :t, datediff_pay < :n).

Everything else is fixed at modeling time. A parameter reference in a metric's expression, in a query's where_sql, or in any other extension key is an error, never a silently rendered literal. There are no parameterized dates, grains, dimensions, aggregate functions, or dialects.


Declaring a parameter

Parameters are declared on the metric, in the params list of its DATUS extension entry, alongside the window or derive payload whose hole they fill. Reference the hole with {"param": "<name>"} in a window slot, or with :<name> in a filter predicate.

metrics:
  # A rolling width as a parameter: `moving_avg` at n=3 is the classic
  # 3-month moving average; n=[1,3,6] is three columns in one query.
  - name: moving_avg
    description: n-month rolling average (n is a query-time parameter)
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "SUM(sales.amount)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: |
          {"v": "1.5",
           "params": [{"name": "n", "type": "int", "default": 3, "min": 1, "max": 12,
                       "description": "trailing window width in buckets of the queried grain"}],
           "window": {"type": "rolling", "function": "avg", "periods": {"param": "n"}}}

  # A filter literal as a parameter. The authored expression is the default
  # (n = 7) instantiation — the two must agree.
  - name: paid_within_n
    description: Payments within the first n days after registration
    expression:
      dialects:
        - dialect: ANSI_SQL
          expression: "SUM(CASE WHEN base.datediff_pay < 7 THEN base.sum_pay_amount END)"
    custom_extensions:
      - vendor_name: DATUS
        data: |
          {"v": "1.5",
           "params": [{"name": "n", "type": "int", "default": 7,
                       "allowed": [1, 3, 7, 14, 30, 60, 90, 180],
                       "description": "days since registration, exclusive upper bound"}],
           "derive": {"type": "filter", "base": "pay_total", "where": "datediff_pay < :n"}}

Each entry in params carries:

Field Required Meaning
name yes [a-z][a-z0-9_]*, unique within the metric; this is what a query binds
type yes int, float or string
default yes the value used when a query binds nothing; must lie in the domain
allowed either an explicit list of legal values
min / max or a closed range, either side optional
description no shown in the catalog; write it for the agent that will read it

allowed and min/max are mutually exclusive. A string parameter must declare allowed — an unbounded string parameter would be an arbitrary dimension value rather than a governed choice — and takes no min/max. An int or float parameter with no domain at all still compiles, with a datus_param_unbounded warning; a parameter no slot references warns with datus_param_unused.

One parameter may fill several slots, and one predicate may hold several parameters (datediff_pay < :n AND platid = :plat). A predicate made only of parameters (:n < 7) is rejected — a filter must still test a column.

A metric that declares parameters but is queried without any behaves exactly like an unparameterized metric at its defaults, down to the generated SQL. A metric derived from parameterized members inherits their parameters: if ltv = paid_within_n / new_user_cnt, then ltv takes n without declaring it.


Binding at query time

Bindings are per query, by parameter name, and reach every queried metric that declares that name — directly or through its derive members.

dosi --model model.yaml query --metrics moving_avg \
  --group-by metric_time:month --param n=6

--param is repeatable (--param n=6 --param k=4). A comma-separated value is a list; quote a string value that itself contains a comma (--param city=NYC,"Paris, FR" is two values).

{"metrics": ["amount_total", "moving_avg"],
 "group_by": [{"field": "metric_time", "grain": "month"}],
 "params": {"n": 6}}              // one binding
{"metrics": ["moving_avg"],
 "group_by": [{"field": "metric_time", "grain": "month"}],
 "params": {"n": [1, 3, 6]}}      // a list: one column per value

Omitted parameters take their declared defaults. Binding nothing is always legal and always means "the metric as modeled".

A list expands the metric into one column per value. Two lists take the cartesian product, first declared parameter outermost. The expansion is capped at 64 metric columns per query.

Types are strict. An int parameter rejects 7.5 and "7". The single implicit conversion is that a float parameter accepts a JSON integer.


Reading the result

A default binding leaves the column named after the metric — moving_avg. A non-default binding names it {metric}__{param}_{value}:

moving_avg__n_6            # n = 6
ltv__n_30                  # inherited parameter
rate__t_1_5                # float 1.5
region_share__r_o_neil     # strings are sanitized

Several parameters append in declaration order, and under a list binding every column is suffixed — including the one at the default. The rule exists so two dashboards can never show the same metric name over different widths.

Every response also carries the machine-readable form, outputs — one entry per metric column with its metric and the parameters that produced it, defaults included:

$ dosi --model model.yaml query --metrics amount_total,moving_avg \
    --group-by metric_time:month --param n=3,6 --format json --execute --db sales.duckdb
{
  "dialect": "duckdb",
  "sql": "WITH base AS (…) SELECT … AVG(sales_amount_sum) OVER (ORDER BY metric_time__month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg__n_3, … AS moving_avg__n_6 FROM base",
  "outputs": [
    {"column": "amount_total",    "metric": "amount_total"},
    {"column": "moving_avg__n_3", "metric": "moving_avg", "params": {"n": 3}},
    {"column": "moving_avg__n_6", "metric": "moving_avg", "params": {"n": 6}}
  ],

}

outputs appears in the CLI JSON, the REST and MCP query responses, the Python result dicts, and explain; over Arrow the same information rides as field metadata (dosi.metric, dosi.params). Read it instead of parsing column names.

One consequence for ordering: a bare metric name in order_by resolves only while that metric is a single column. Under a list binding, name the column (moving_avg__n_6).


Discovering what a metric takes

dosi list metrics --format json, GET /v1/metrics, and the MCP list_metrics tool carry each metric's params — the declaration plus the slots it fills, and declared_on when the parameter is inherited from a member:

$ dosi --model model.yaml list metrics --format json
{
  "name": "moving_avg",
  "params": [
    {"name": "n", "type": "int", "default": 3, "min": 1, "max": 12,
     "description": "trailing window width in buckets of the queried grain",
     "slots": ["rolling.periods"]}
  ],

}

For agents, the MCP describe_metric tool adds param_schema — a JSON Schema for the params map, with allowed rendered as enum and min/max as minimum/maximum. An agent reads the schema, then binds within it.

Whether an engine supports parameters at all is in dosi info --format json and GET /v1/capabilities, as the params key of the DATUS extension registry ("since": "1.5").


Limits and boundaries

  • Six slots, listed above. Dates, grains, dimensions, aggregate functions, and dialects are not parameterizable.
  • One binding set per query. params applies to every queried metric that declares the name; there is no per-metric override, so two metrics sharing a parameter name are asked at the same value.
  • 64 metric columns per query after list expansion (param_expansion_too_large).
  • Attribution binds one value per parameter. dosi attribute --param n=30 is fine; a list is refused with a single-value retry. The resolved bindings come back in comparison_metadata.params. See attribution.md.
  • Basic mode ignores parameters. Under --osi-basic the D-WINDOW and D-DERIVE hosts are ignored, so every slot takes its declared default and a query-time params is rejected as unknown_metric_param.
  • Older engines. An engine below DATUS 1.5 rejects the model at the version gate. A 1.4 server, however, tolerates unknown request fields: it silently drops a client's params and answers at the defaults. Check GET /v1/capabilities (or dosi info) before relying on binding remotely.

Errors

Every rejection is structured, names the metric, and carries a suggested_retry you can send back unchanged. Full catalog in errors.md.

Code Raised when
unknown_metric_param no queried metric declares the bound name; candidates lists the names that are declared
param_out_of_domain the value is the wrong type or outside the domain — also an empty or repeated list, or a list where one value is required
param_expansion_too_large list bindings expand past 64 metric columns
$ dosi --model model.yaml query --metrics moving_avg --group-by metric_time:month --param n=0 --format json
{
  "code": "param_out_of_domain",
  "message": "parameter \"n\" must be in [1, 12] (declared on metric \"moving_avg\")",
  "metrics": ["moving_avg"],
  "suggested_retry": "\"params\": {\"n\": 3}"
}

See also