Template Expressions
Write dynamic SQL with param and filter expressions, conditional blocks, and list expansion
Template expressions let you write dynamic SQL that reads dashboard controls (via {{ param(...) }}), reads active filters (via {{ filter(...) }}), branches with {% if %} blocks, and expands multi-select values into IN lists. The syntax is a small subset of Jinja, so it will feel familiar if you have used dbt or similar tools.
For simple cases, keep using the dedicated forms: [[param.x]] to substitute a single control value, or {{ filters | where }} to subscribe a card to the dashboard filter bar. Use template expressions when you need conditional SQL, multi-value IN lists, explicit defaults, or logic that combines parameters and filters.
When to use template expressions
| Need | Syntax |
|---|---|
| Insert a single value | [[param.region]] or {{ param('region') }} |
Expand a multi-select into IN (...) | {{ param('ids').list }} |
| Branch on a control value | {% if param('grain').value == 'day' %}...{% endif %} |
| Fall back to a default | {{ param('grain', 'month') }} |
| Gate a predicate on a filter being set | {% if filter('segment').present %}...{% endif %} |
| Reuse a date range on another field | BETWEEN {{ filter('date').start }} AND {{ filter('date').end }} |
Accessors at a glance
param and filter share the core accessors. Date range filters additionally
expose .start and .end. Output accessors render SQL text inside {{ ... }};
condition accessors return raw values for use inside {% if ... %} blocks.
| Accessor | Use in | Returns | Example |
|---|---|---|---|
.sql | {{ ... }} output | SQL-quoted scalar | {{ param('region').sql }} renders 'West' |
.list | {{ ... }} output | Parenthesized SQL list for IN (...) | WHERE id IN {{ param('ids').list }} renders IN (1, 2, 3) |
.list_sql | {{ ... }} output | Alias for .list | -- |
.start | {{ ... }} output | SQL-quoted first bound of a date range filter | {{ filter('order_date').start }} renders '2026-01-01' |
.end | {{ ... }} output | SQL-quoted second bound of a date range filter | {{ filter('order_date').end }} renders '2026-01-31' |
.value | {% if ... %} condition | Raw scalar for comparisons | {% if param('grain').value == 'day' %} |
.values | {% if ... %} condition | Raw list for comparisons | {% if 'VIP' in filter('tier').values %} |
.present | {% if ... %} condition | Boolean; true if a non-null value exists | {% if filter('region').present %} |
Bare form is shorthand for `.sql`
{{ param('id') }} is exactly equivalent to {{ param('id').sql }} -- the bare form renders the SQL-quoted literal. The same holds for {{ filter('name') }} and {{ filter('name').sql }}. Use the explicit .sql accessor only when it makes intent clearer; most of the time, the bare form is all you need.
Accessors do not mix contexts
.sql, .list, .list_sql, .start, and .end are only valid in output positions ({{ ... }}). .value, .values, and .present are only valid inside {% if %} conditions. Using the wrong one raises a template error. .start and .end are available only on filter(...), not param(...).
Parameter expressions
{{ param('id') }} resolves to the dashboard or card control with the matching ID. Card-scoped controls take precedence over dashboard-scoped controls with the same ID, matching the behavior of [[param.id]].
Single value
SELECT * FROM orders WHERE tenant = {{ param('tenant_id') }}With tenant_id set to acme_corp:
SELECT * FROM orders WHERE tenant = 'acme_corp'Numeric and boolean controls render without quotes:
SELECT {{ param('account_id') }} AS account_idWith account_id set to 42:
SELECT 42 AS account_idList expansion
Multi-select controls render as parenthesized SQL lists ready for IN:
SELECT category, COUNT(*) AS order_count
FROM orders
WHERE id IN {{ param('ids').list }}
GROUP BY categoryWith ids set to [1, 2, 3]:
SELECT category, COUNT(*) AS order_count
FROM orders
WHERE id IN (1, 2, 3)
GROUP BY categoryThe simpler WHERE id IN ([[param.ids]]) form works for the same case. Use .list when you want the parentheses emitted automatically.
Defaults
Pass a second argument to param to supply a fallback when the control has no value:
SELECT {{ param('grain', 'month') }} AS grainIf the viewer has not set grain:
SELECT 'month' AS grainGuarding on .present
To branch on whether a control has a value, use .present:
SELECT * FROM orders
{% if param('region').present %}
WHERE region = {{ param('region') }}
{% endif %}Use not param('x').present to run logic specifically when a control is empty -- for example, to apply a default row limit:
SELECT * FROM orders
ORDER BY order_date DESC
{% if not param('row_limit').present %}
LIMIT 100
{% endif %}A few behaviors worth knowing:
- Optional controls with no value resolve to
NULLunder the[[param.x]]syntax, but report.present == falseunder template expressions. That is the cleanest way to skip a predicate entirely. - If a control is required and the viewer has not made a selection, the card shows the same error state as today. The query does not run, regardless of which syntax you use.
Filter expressions
{{ filter('name') }} complements the {{ filters | where }} helper. Use it when you need a specific filter's value, a list of its selected values, or conditional logic that depends on whether the filter is active.
Single value
SELECT * FROM orders WHERE region = {{ filter('region') }}SELECT * FROM orders WHERE region = 'West'List expansion
SELECT * FROM orders WHERE status IN {{ filter('status').list }}SELECT * FROM orders WHERE status IN ('open', 'closed')Reusing date range bounds on another field
A date range filter contains a start bound and an end bound. Use the
filter-only .start and .end output accessors when the dashboard filter is
defined on one date field but the card SQL needs to apply the selected range to
another date or timestamp field.
Prefer card mapping for ordinary filter propagation
If you only need the same dashboard selection to target a different field on
each card, configure that field from the filter's Cards tab.
Use .start and .end when your custom SQL needs to place the date bounds in
a predicate or expression that you write yourself.
For example, suppose the dashboard filter is named order_date, but this card
needs to constrain shipped_at:
SELECT *
FROM orders
WHERE shipped_at BETWEEN {{ filter('order_date').start }}
AND {{ filter('order_date').end }}With order_date set to January 1–31, 2026:
SELECT *
FROM orders
WHERE shipped_at BETWEEN '2026-01-01'
AND '2026-01-31'Semaphor SQL-quotes and escapes both bounds. Do not add quotes around the expressions yourself.
Date range accessors require a bounded date filter
.start and .end require an active date filter whose operation is between or not between and whose value contains exactly two non-null bounds. They are not array accessors: {{ filter('order_date').value[0] }} is not supported. To apply the date filter to its original field instead, use {{ filters['order_date'] | where }}.
Guarding on .present
Gate a predicate on the filter being set:
SELECT * FROM orders
WHERE 1 = 1
{% if filter('segment').present %}
AND segment = {{ filter('segment') }}
{% endif %}When segment is not active, the {% if %} block is dropped entirely.
Use not filter('x').present to run logic specifically when a filter is missing -- for example, to return zero rows until the viewer picks a tenant:
SELECT * FROM orders
WHERE 1 = 1
{% if not filter('tenant_id').present %}
AND 1 = 0
{% else %}
AND tenant_id = {{ filter('tenant_id') }}
{% endif %}With no tenant_id filter applied:
SELECT * FROM orders
WHERE 1 = 1
AND 1 = 0Conditional blocks
{% if %}, {% elif %}, {% else %}, and {% endif %} let you include or exclude SQL fragments based on control and filter state. Conditions can combine param and filter expressions freely.
Grain switcher driven by a control:
SELECT
{% if param('grain').value == 'day' %}
DATE_TRUNC('day', order_date)
{% elif param('grain').value == 'week' %}
DATE_TRUNC('week', order_date)
{% else %}
DATE_TRUNC('month', order_date)
{% endif %} AS bucket,
COUNT(*) AS orders
FROM orders
GROUP BY 1With grain = 'day':
SELECT
DATE_TRUNC('day', order_date) AS bucket,
COUNT(*) AS orders
FROM orders
GROUP BY 1Supported operators inside {% if %}:
- Comparison:
==,!=,<,>,<=,>= - Boolean:
and,or,not - Membership:
in(e.g.{% if 'VIP' in filter('tier').values %})
Combining parameters and filters
A card that combines a required tenant parameter, an optional segment filter, and a grain switcher:
SELECT
{% if param('grain').value == 'day' %}
DATE_TRUNC('day', order_date)
{% else %}
DATE_TRUNC('month', order_date)
{% endif %} AS bucket,
COUNT(*) AS orders
FROM orders
WHERE tenant_id = {{ param('tenant_id') }}
{% if filter('segment').present %}
AND segment IN {{ filter('segment').list }}
{% endif %}
GROUP BY 1
ORDER BY 1With tenant_id = 'acme_corp', grain = 'day', and a segment filter of ['enterprise', 'mid_market']:
SELECT
DATE_TRUNC('day', order_date) AS bucket,
COUNT(*) AS orders
FROM orders
WHERE tenant_id = 'acme_corp'
AND segment IN ('enterprise', 'mid_market')
GROUP BY 1
ORDER BY 1Safety
Template expressions SQL-quote and escape values automatically. You never wrap them in quotes yourself.
SELECT * FROM orders WHERE name = {{ param('name') }}With name set to O'Brien:
SELECT * FROM orders WHERE name = 'O''Brien'Do not wrap expressions in quotes
Writing '{{ param('region') }}' is an error -- the same rule as the [[param.region]] placeholder syntax. Multi-value controls render as SQL lists, not concatenated strings, so they cannot be string-interpolated either.
What's supported
Supported:
param(id)andparam(id, default)expressionsfilter(name)expressions- Accessors:
.sql,.list,.list_sql,.value,.values,.present - Filter-only date range output accessors:
.start,.end {% if %},{% elif %},{% else %},{% endif %}blocks- Comparison operators:
==,!=,<,>,<=,>= - Boolean operators:
and,or,not - Membership:
in
Not supported:
- Loops (
{% for %}) - Variable assignment (
{% set %}) - Raw blocks (
{% raw %}) - Macros and custom filter pipelines
- Any template syntax on cached-source connections (S3, S3 Tables, Google Sheets, uploaded files) -- those datasets reject template expressions at save and run time
Related
Controls
Dashboard and card-level inputs that drive {{ param(...) }} values
SQL Controls
The simpler [[param.x]] placeholder syntax for drop-in value substitution
Filters
How filters flow into queries, including the {{ filters | where }} helpers
Virtual Datasets
Use template expressions in SQL-backed semantic datasets