Semaphor

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

NeedSyntax
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 fieldBETWEEN {{ 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.

AccessorUse inReturnsExample
.sql{{ ... }} outputSQL-quoted scalar{{ param('region').sql }} renders 'West'
.list{{ ... }} outputParenthesized SQL list for IN (...)WHERE id IN {{ param('ids').list }} renders IN (1, 2, 3)
.list_sql{{ ... }} outputAlias for .list--
.start{{ ... }} outputSQL-quoted first bound of a date range filter{{ filter('order_date').start }} renders '2026-01-01'
.end{{ ... }} outputSQL-quoted second bound of a date range filter{{ filter('order_date').end }} renders '2026-01-31'
.value{% if ... %} conditionRaw scalar for comparisons{% if param('grain').value == 'day' %}
.values{% if ... %} conditionRaw list for comparisons{% if 'VIP' in filter('tier').values %}
.present{% if ... %} conditionBoolean; 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

Card SQL
SELECT * FROM orders WHERE tenant = {{ param('tenant_id') }}

With tenant_id set to acme_corp:

Resolved SQL
SELECT * FROM orders WHERE tenant = 'acme_corp'

Numeric and boolean controls render without quotes:

Card SQL
SELECT {{ param('account_id') }} AS account_id

With account_id set to 42:

Resolved SQL
SELECT 42 AS account_id

List expansion

Multi-select controls render as parenthesized SQL lists ready for IN:

Card SQL
SELECT category, COUNT(*) AS order_count
FROM orders
WHERE id IN {{ param('ids').list }}
GROUP BY category

With ids set to [1, 2, 3]:

Resolved SQL
SELECT category, COUNT(*) AS order_count
FROM orders
WHERE id IN (1, 2, 3)
GROUP BY category

The 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:

Card SQL
SELECT {{ param('grain', 'month') }} AS grain

If the viewer has not set grain:

Resolved SQL
SELECT 'month' AS grain

Guarding on .present

To branch on whether a control has a value, use .present:

Card SQL
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:

Card SQL
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 NULL under the [[param.x]] syntax, but report .present == false under 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

Card SQL
SELECT * FROM orders WHERE region = {{ filter('region') }}
Resolved SQL
SELECT * FROM orders WHERE region = 'West'

List expansion

Card SQL
SELECT * FROM orders WHERE status IN {{ filter('status').list }}
Resolved SQL
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:

Card SQL
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:

Resolved SQL
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:

Card SQL
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:

Card SQL
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:

Resolved SQL
SELECT * FROM orders
WHERE 1 = 1
    AND 1 = 0

Conditional 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:

Card SQL
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 1

With grain = 'day':

Resolved SQL
SELECT
  DATE_TRUNC('day', order_date) AS bucket,
  COUNT(*) AS orders
FROM orders
GROUP BY 1

Supported 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:

Card SQL
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 1

With tenant_id = 'acme_corp', grain = 'day', and a segment filter of ['enterprise', 'mid_market']:

Resolved SQL
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 1

Safety

Template expressions SQL-quote and escape values automatically. You never wrap them in quotes yourself.

Card SQL
SELECT * FROM orders WHERE name = {{ param('name') }}

With name set to O'Brien:

Resolved SQL
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) and param(id, default) expressions
  • filter(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

On this page