MiniJinja Templating - dathere/qsv GitHub Wiki
Tier: Intermediate
Commands that use it: template, apply, fetchpost, describegpt, profile
Note
This page is the cross-cutting templating layer. Per-command flags live in /docs/help/. For the full template language, see the MiniJinja docs and the Jinja2 template syntax reference.
Several qsv commands embed MiniJinja (currently v3.0), a Rust implementation of Jinja2. Wherever you see the โฉ๏ธ symbol in the Command Reference, that command renders text from your data with the same template language โ so a filter you learn for template works the same in a fetchpost payload, a describegpt prompt, or a profile formula.
Breaking: minijinja 3.0 output changes (25.0.0, #4785)
Warning
qsv 25.0.0 bumped minijinja 2.24 โ 3.0, which changes rendered template output. This affects template, apply, fetchpost, describegpt and profile. Only division by zero newly errors; everything else just renders differently.
| Expression | Was (2.24) | Now (3.0) |
|---|---|---|
{{ {"a": 1, "b": [1, 2]}|tojson }} |
{"a":1,"b":[1,2]} |
{"a": 1, "b": [1, 2]} |
{{ 2.5|round }} / {{ 0.125|round(2) }}
|
3.0 / 0.13
|
2.0 / 0.12 (round half to even) |
{{ ["a", "b"] }} / {{ {"k": "v"} }}
|
["a", "b"] / {"k": "v"}
|
['a', 'b'] / {'k': 'v'}
|
{{ (1, 2) }} |
[1, 2] |
(1, 2) |
{{ "o'brien"|title }} |
O'Brien |
O'brien |
{{ 1 / 0 }} |
inf |
rendering error |
{{ missing is sequence }} |
False |
True |
{{ d|datetimeformat(format="[year]-[month]-[day]") }} |
2023-06-24 |
[year]-[month]-[day] (printed literally) |
{{ d|datetimeformat(format="%Y-%m-%d") }} |
%Y-%m-%d |
2023-06-24 |
The datetime row is the one to look for. The contrib datetimeformat, dateformat and timeformat filters now use strftime formats (%Y-%m-%d, as qsv's own datefmt always has). An old time-crate format doesn't fail, it prints itself, so nothing warns you. Named formats (short, medium, long, full, iso, unix) are unchanged.
is sequence on a missing value is now True, so {% if x is sequence %} takes the list branch when x is absent. Guard it: {% if x is defined and x is sequence %}. qsv's bundled profiles already gate these fields on is defined, so their output doesn't change.
tojson output is still valid JSON, so fetchpost JSON payloads and profile output parse exactly as before; only byte-level comparisons of the text see the spacing change. To print a list as JSON, use |tojson rather than interpolating it.
Better error messages. Rendering errors now name the undefined expression (in fetchpost too, inside its own error message). In template:
RENDERING ERROR (1): undefined value: `cfg` is undefined (in template:1)
where 2.24 printed only undefined value.
{# half-up rounding of a non-negative number to 2 places #}
{{ (((x|float) * 100 + 0.5)|int / 100) }}
{# old-style datetime format โ strftime #}
{{ d|datetimeformat(format="%Y-%m-%d") }}Warning
22.0.1 bumped minijinja 2.21 โ 2.23, which changes rendered template output. This affects template, apply, fetchpost, describegpt and profile. Nothing errors โ the output just differs, so a pipeline that writes files can silently start emitting different text.
Upstream minijinja #913 aligned scalar rendering with Jinja2:
| Expression | Was (โค 2.21) | Now (2.23) |
|---|---|---|
| A boolean, interpolated directly |
true / false
|
True / False
|
| A none-valued expression | (empty string) | None |
This reaches any template that interpolates a boolean directly โ including qsv's own to_bool and regex_match filters, e.g. {{ active|to_bool }} โ and any none-valued expression, most notably a JSON null reached through fromjson:
{{ (meta|fromjson).author }} {# was: nothing. now: None #}Templates that only test booleans are unaffected โ {% if active|to_bool %} behaves exactly as before.
{# none โ empty string #}
{{ value or "" }}
{{ value|default("", true) }}
{# booleans โ lowercase #}
{% if b %}true{% else %}false{% endif %}Caution
A bare {{ value|default("") }} does not help. default substitutes only for undefined, not for none โ you need the second argument (default("", true)) or the or "" form.
Upstream minijinja #909 fixed the split filter to return a sequence rather than a lazy unsized iterator, so loop.last (and negative indexing/slicing) now work over split results. A separator idiom like this no longer emits a spurious trailing separator:
{% for x in col|split(',') %}{{ x }}{% if not loop.last %}, {% endif %}{% endfor %}| Command | What gets templated | How to supply the template |
|---|---|---|
template |
An arbitrary text/Markdown/HTML/CSV-per-row document |
--template <str> or -t, --template-file <file>
|
fetchpost |
The HTTP POST request body (JSON or any content type) | -t, --payload-tpl <file> |
describegpt |
The LLM prompt(s) sent for inference |
--prompt-file <toml> (templated prompt fields) |
profile |
CKAN scheming formula / suggestion_formula fields โ derived metadata |
--spec <yaml> |
In every case, CSV column values become template variables and the rendered output is what the command emits or sends.
For per-row commands (template, fetchpost), each row is rendered independently:
-
Column headers become variable names. Non-alphanumeric characters are converted to underscore:
first nameโ{{ first_name }},us-stateโ{{ us_state }}. -
With
--no-headers, columns are addressed by 1-based index with a_cprefix:{{ _c1 }},{{ _c2 }}, โฆ (template). -
QSV_ROWNOholds the current 1-based row number โ handy for output filenames (--outfilenameintemplate). -
All field values are strings. Cast with
|intor|floatbefore math or before any filter that needs a number (see Tips below).
template (-J, --globals-json <file>) and fetchpost accept a JSON file of values shared across every row render, accessed under the qsv_g namespace:
Report for {{ qsv_g.school_name }} โ {{ qsv_g.year }}
Student: {{ last_name|title }}, {{ first_name|title }}Beyond the stock Jinja2 filters and the minijinja-contrib set, template registers these qsv-specific helpers. They do not require casting โ they accept the raw string field for convenience:
| Filter | Purpose | Example |
|---|---|---|
substr(start[, end]) |
Substring by byte range | `{{ code |
format_float(precision) |
Parse โ fixed-precision float (max 16) | `{{ balance |
human_count |
Integer with thousands separators | `{{ rows |
human_float_count |
Float with thousands separators | `{{ amount |
round_banker(places) |
Banker's rounding (round-half-to-even) | `{{ rate |
to_bool |
Truthiness of true/1/yes/t/y or non-zero number |
`{% if active |
lookup("table", "Column") |
Value from a registered lookup table | `{{ us_state |
Plus one custom function:
| Function | Purpose |
|---|---|
register_lookup("name", "resource") |
Load a lookup table (local path, HTTP/HTTPS, dathere://, or ckan://) and bind it to name for |lookup. Returns true on success. |
Important
Filter errors are values, not crashes. When a custom filter can't parse its input (e.g. format_float on non-numeric text), it returns the --customfilter-error string (default <FILTER_ERROR>) instead of aborting the run. Set --customfilter-error "<empty string>" to emit nothing on error.
register_lookup() is pre-scanned from the template body before rendering, so the table is ready on the first row. Because of this pre-scan, a register_lookup(...) call buried inside a conditional still runs at startup.
{% set ok = register_lookup("us_states", "dathere://us-states-example.csv") -%}
{% if ok and us_state not in ["DE", "CA"] -%}
{% set tax = us_state|lookup("us_states", "Sales Tax (2023)")|float -%}
{{ us_state|lookup("us_states", "Name") }}: {{ tax }}%
{% endif %}See Lookup Tables for resource schemes, caching (--cache-dir, QSV_CACHE_DIR), and CKAN options (--ckan-api, --ckan-token).
These fill gaps that neither MiniJinja core nor minijinja-contrib cover (regex, integer-exact rounding, messy-date parsing, padding, slugs, hashing). They are registered on every MiniJinja-powered command โ template, fetchpost, describegpt, and profile โ and are present in all binary variants (qsv, qsvlite, qsvdp, qsvmcp), with no cargo feature gate. Values are coerced from strings, so you usually don't need |float first.
| Filter | Purpose | Example |
|---|---|---|
regex_replace("pat", "rep") |
Replace all regex matches; $1 / ${name} capture refs in the replacement |
`{{ phone |
regex_match("pat") |
true if the regex matches anywhere |
`{% if id |
regex_find("pat") |
First whole match, or "" if none |
`{{ text |
floor / ceil
|
Round down / up. Integer inputs stay exact (incl. values beyond f64's 2โตยณ range, up to u64); fractional inputs return a float | `{{ "42.7" |
datefmt("fmt"[, prefer_dmy]) |
Parse a messy date string (19+ formats, via qsv-dateparser) and reformat with a chrono format string. Unlike contrib's dateformat, this parses arbitrary strings |
`{{ d |
zfill(width) |
Left-pad with zeros, keeping a leading sign | `{{ "42" |
lpad(width[, fill]) / rpad(width[, fill])
|
Left / right pad to width with fill (default space) |
`{{ name |
slugify |
URL/DB/CKAN-safe slug (lowercase, non-alphanumeric runs โ -, trimmed) |
`{{ title |
blake3 |
BLAKE3 hex digest of the value โ stable surrogate / content keys for dedup, joins, change-detection | `{{ row |
fromjson / parse_json
|
Parse a JSON-in-a-cell string into an indexable value | `{{ (meta |
Plus one function:
| Function | Purpose |
|---|---|
coalesce(a, b, โฆ) |
First argument that is not undefined / none / empty string (broader than the single-fallback default/d) |
Notes:
-
floor/ceilprecision. Integer inputs pass through exactly (signedi64and large unsignedu64IDs alike); an integer literal too large for either is rejected with an error rather than silently approximated. Fractional inputs go throughf64and return a float โ pipe|intfor a clean integer. - Regex caching. Compiled patterns are cached (bounded) for reuse across rows, so a literal pattern compiles once.
-
Errors. Invalid regex, unparseable dates, malformed JSON, and non-numeric
floor/ceilinputs raise a template error. Intemplate, that surfaces as a per-rowRENDERING ERROR(counted), not a crash.
describegpt --infer-content-type uses datefmt in its default dictionary template. For Date/DateTime fields, the LLM infers the column's actual strftime format (validated against the data) and stamps it onto the Content Type, e.g. date:%m/%d/%Y or datetime:%m/%d/%Y %I:%M:%S %p. The dictionary's Min and Max come from qsv stats normalized to RFC 3339 (2013-01-24), which looks different from how the dates actually appear in the data. The template extracts the inferred format and reformats Min/Max so they match the column's real presentation (and the verbatim Examples/Enumeration values):
{% set df = entry.content_type | regex_replace("^(date|datetime):", "")
if entry.content_type | regex_match("^(date|datetime):") else "" %}
{% if df and entry.min %}{{ entry.min | datefmt(df) }}{% else %}{{ entry.min }}{% endif %}So a column whose dates read 01/24/2013 shows Min/Max as 01/24/2013 too, not 2013-01-24. Fields without an inferred date format (bare date/datetime, or non-date content types) are left unchanged. Custom --prompt-file dictionary templates can adopt the same {% set df %} pattern. The ^(date|datetime): anchor matches only the bare-token prefix up to the first :, so formats containing colons (%I:%M:%S) are preserved intact.
profile's --spec formulas run in a richer environment โ a native Rust port of DataPusher+'s jinja2_helpers.py โ with metadata-oriented helpers such as format_bytes, format_date, format_coordinates, calculate_percentage, sanitize_iso_8601_interval, spatial_extent_wkt, temporal_resolution, guess_accrual_periodicity, build_csvw_schema, and build_croissant_fields. These are specific to the profiling pipeline; see Metadata Profiling and DataPusher+'s dataset-druf.yaml for usage.
qsv compiles MiniJinja with these capabilities, so they're available in every templated command:
-
pycompatโ call Python-style string methods directly:{{ name.upper() }},{{ s.startswith("A") }},{{ s.strip() }}. -
datetime+timezone(minijinja-contrib) โdatetimeformat,dateformat,timeformat,now()for date/time rendering. -
urlencodeโ percent-encode values, important forfetchpostpayloads:{{ q|urlencode }}. -
loop_controlsโ{% break %}/{% continue %}inside{% for %}loops. -
rand(minijinja-contrib) โrandom/randrange-style helpers. -
Text shaping (
minijinja-contrib) โwordwrap,wordcount, Unicode-aware word wrapping. -
jsonโ{{ obj|tojson }}for emitting valid JSON (the backbone offetchpostJSON payloads). -
speedups/stackerโ performance and deep-recursion safety; no syntax impact.
Cast before you compute. Fields arrive as strings. Stock Jinja math and many filters need numbers:
{{ (balance|float - discount|float)|format_float(2) }}
{{ "%.1f"|format(score|float) }}Trim whitespace with -. Add a minus to a block's delimiters to strip surrounding whitespace/newlines โ essential when generating compact JSON or clean CSV:
{%- for r in rows -%}
{{ r.id }}{% if not loop.last %},{% endif %}
{%- endfor -%}Comments don't render. Use {# โฆ #} for notes that won't appear in output.
fetchpost: keep JSON valid. Build the body with |tojson and |urlencode rather than hand-quoting. fetchpost validates the rendered JSON and aborts on malformed output, so let the filters do the escaping:
{"name": {{ full_name|tojson }}, "q": "{{ query|urlencode }}"}describegpt: prompts are templates too. The --prompt-file TOML's prompt fields are MiniJinja โ you can interpolate dataset stats/frequency/dictionary context into the LLM prompt. See resources/describegpt_defaults.toml for the built-in templates.
Use loop.* variables. loop.index, loop.first, loop.last, loop.length make separators and headers easy in template documents.
Python string methods work (via pycompat): {{ code.replace("-", "") }}, {{ name.title() }}, {{ s.split(",") }}.
-
MiniJinja crate docs ยท
minijinja-contribยท Jinja2 template syntax -
templateโ render CSV rows into any text format ยท/docs/help/template.md -
fetchpostโ MiniJinja-templated POST bodies ยท/docs/help/fetchpost.md -
describegptโ templated LLM prompts ยท/docs/help/describegpt.md -
Metadata Profiling (
profile) โ CKAN scheming formulas -
Lookup Tables โ
register_lookup/\|lookupresource schemes & caching -
tests/test_template.rsโ worked examples ยทscripts/template.tplโ a complex real template