Askama (Rust Templating)
Captured 2026-07-10 (askama 0.16.0). To update: re-verify against the askama book + docs.rs/askama, then diff for changes.
Askama is a compile-time, Jinja-like template engine for Rust. Templates are parsed at build time and generate type-checked Rust code. This skill targets the unified askama crate, version 0.16.x (MSRV 1.88, raised in 0.15.0 from 1.83; was 1.81 through 0.13.x, then 1.83 in 0.14.0). Anything older than 0.13 wired web frameworks through dedicated askama_* crates that no longer exist; see the breaking-changes section if migrating.
Quick history: Askama was forked into
rinjain 2024, then the two projects re-unified asaskamain early 2025. If the user mentionsrinjaorrinja_axum, those are deprecated. Migrate toaskama/askama_web.
1. Current State & Setup
Cargo.toml
[dependencies]
askama = "0.16"
# For web frameworks, ADD askama_web separately (see §3):
askama_web = { version = "0.16", features = ["axum-0.8"] }
Do not depend on askama_axum, askama_actix, askama_warp, or askama_rocket (deprecated when 0.13 unified them), nor askama_gotham / askama_tide (never deprecated; just abandoned since 2023 on askama ^0.12). All dead now. There is no askama_actix_web crate.
Minimal Example
templates/hello.html:
Hello, {{ name }}!
use askama::Template;
#[derive(Template)]
#[template(path = "hello.html")]
struct HelloTemplate<'a> { name: &'a str }
HelloTemplate { name: "world" }.render()? // -> Result<String, askama::Error>
Templates live in templates/ at the crate root (next to Cargo.toml) by default. Override via askama.toml (see §11).
2. Rendering API: Use the Right Method
Prefer these over .to_string() or format!(), which route through a vtable at runtime and run 100-200% slower:
| Method | Output | When to use |
|---|---|---|
tmpl.render() |
Result<String, askama::Error> |
Default. Allocates a new String. |
tmpl.render_into(&mut writer) |
writes into impl fmt::Write |
Reusing a buffer, nested rendering. |
tmpl.write_into(&mut writer) |
writes into impl io::Write |
Writing directly to a socket / file. |
The Template trait exposes render_with_values(&dyn Values) for runtime-injected values that aren't on the struct.
The derive auto-computes a SIZE_HINT const from the template's static text; render() / render_with_values preallocate their String with it (no effect on the caller-owned writers of render_into / write_into). There is no size_hint attribute; #[template(size_hint = ...)] is a compile error (unsupported template attribute). Override the hint only by hand-implementing Template with a custom const SIZE_HINT.
Removed in 0.13+: Template::EXTENSION and Template::MIME_TYPE associated fields no longer exist. Do not reference them.
3. Web Framework Integration
Two paths. Pick one.
Path A: Manual (Simplest, No Extra Crate)
Call .render() and wrap the string yourself.
// axum
use axum::response::{Html, IntoResponse};
async fn index() -> impl IntoResponse {
Html(IndexTemplate { title: "Home" }.render().unwrap())
}
For proper error handling, return Result<Html<String>, AppError> where AppError: IntoResponse and AppError::from(askama::Error) exists.
Path B: askama_web with WebTemplate
One derive implements the response trait for whichever feature flag you turned on (see the list below).
askama_web = { version = "0.16", features = ["axum-0.8"] }
use askama::Template;
use askama_web::WebTemplate;
#[derive(Template, WebTemplate)]
#[template(path = "hello.html")]
struct HelloTemplate { name: String }
async fn hello() -> HelloTemplate {
HelloTemplate { name: "world".into() }
}
Returns 200 OK, Content-Type: text/html; charset=utf-8. Render errors become 500.
Per-target feature flags for askama_web:
axum-0.8/axum-0.7/axum-core-0.5/axum-core-0.4actix-web-4rocket-0.5warp-0.4/warp-0.3poem-3trillium-0.2cot-0.3/cot-0.4/cot-0.5/cot-0.6/cot_core-0.6derive(WebTemplate derive, on by default) andeprintln. Standalone logging-backend flagslog-0.4/tracing-0.1(one of each, shared across all integrations)
4. #[template(...)] Attribute Options
| Key | Example | Purpose |
|---|---|---|
path |
path = "hello.html" |
File in templates dir. Extension drives escaping. |
source |
source = "Hi {{ name }}" |
Inline template; requires ext. |
ext |
ext = "html" |
Content-type hint; "jinja" / "jinja2" aliases for editor syntax highlighting. |
escape |
escape = "none" or "html" |
Override auto-escape. |
whitespace |
whitespace = "suppress" |
Per-template WS mode ("preserve" / "suppress" / "minimize"); overrides askama.toml. |
syntax |
syntax = "foo" |
Use a custom syntax defined in askama.toml. |
config |
config = "config.toml" |
Override config path (default askama.toml). |
print |
print = "code" / "ast" / "all" / "none" |
Compile-time debug: prints generated code to stdout. |
block |
block = "hello" |
Render just one block. Fields outside that block aren't required. Good for HTMX fragments. |
blocks |
blocks = ["title", "content"] |
Generates as_<block>() accessor methods on the struct, one per block. |
in_doc |
in_doc = true |
Source is in the struct's doc comment (inside an ```askama fenced block). Requires ext. |
askama |
askama = $crate::__askama |
Override the askama crate path. Needed when re-exporting from a macro-defining crate. |
Cannot combine path and source.
path and source must be string literals known at compile time, the same constraint {% include %} paths have (§10). The template can't be chosen at runtime from a variable or computed string.
5. Expressions
Literals and Types
- Struct construction:
{{ MyStruct { field1: 1, field2: "x" }.to_string() }} - Tuple:
{{ (1, 2) }}, array:{{ [1, 2, 3] }}, array-repeat (0.15):{{ [0; 4] }} - Struct expressions are values, not just via
.to_string()(0.15):{{ Point { x: 1, y: 2 } }}
Variable Access
{{ name }}: field on template struct{{ user.name }}: dotted path (fields or methods){{ crate::MAX_USERS }}: use constants from your crate- Reading follows Rust borrow rules. Methods can be called; beware self-recursion.
Operators (Mostly like Rust)
- Bitwise are RENAMED (to avoid filter-pipe conflict):
|->bitor&->bitand^->xor- Precedence unchanged.
{% if my_bitset bitand 1 != 0 %}set!{% endif %}
- Concat
~:{{ a ~ b ~ c }}is shorthand for{{ a }}{{ b }}{{ c }}. Must have spaces around it to disambiguate from whitespace control. ascast:{{ x as i64 }}. Only primitive types. Automatically derefs&&&booletc.
Function and Method Calls
{{ method() }}->self.method()(method on the template struct){{ self::function() }}-> free function in the current module{{ super::b::f() }}-> function in another module{{ (closure)(12) }}-> calling a closure stored in a field; parens required{{ some_macro!(field) }}-> Rust macro. Askama won't infer field references inside macro args. Pass them explicitly, or the macro sees the literal token instead.
6. Control Flow
if / else if / else
{% if a %}…{% else if b %}…{% else %}…{% endif %}
for
{% for user in users %}<li>{{ user.name }}</li>{% endfor %}
- Filter clause:
{% for user in users if user.active %} - Else clause (runs if iter was empty or all filtered out):
{% for u in users %}...{% else %}No one here.{% endfor %} - Loop variables (the complete list; Askama exposes fewer of them than Jinja does):
loop.index(1-based)loop.index0(0-based)loop.firstloop.lastloop.cycle([...])(method call, array-literal arg):{{ loop.cycle(["a", "b"]) }}cycles per iteration
- No
loop.length,loop.revindex,loop.changed,loop.previtem,loop.nextitem. If you need these, compute in Rust or use modulo onloop.index.
match / when (for Enums, Options, Results)
{% match item %}
{% when Some with ("foo") %}
Found literal foo
{% when Some with (val) %}
Found {{ val }}
{% when None %}
Nothing
{% endmatch %}
- Tuple variants:
{% when Variant with (a, b) %} - Struct variants:
{% when Variant { field } %}or{% when Variant { field: val } %}to rename (with-less form since 0.11; 0.8 through 0.10 required thewithkeyword:{% when Variant with { field } %}) - Enum path variants:
{% when Self::Circle { radius } %}.#[derive(Template)]works on the enum itself, giving each variant its own#[template(...)]. - Slice patterns with rest:
{% when [first, ..] %} - Wildcard:
{% when _ %}or{% else %} - Literal patterns:
{% when 3 %}
Assignments and Variable Declarations
{% let name = user.name %} {# immutable binding #}
{% let mut it = xs.iter() %} {# mutable binding #}
{% set x = 4 %} {# alias for let (Jinja compat) #}
{% decl val %} {# declare WITHOUT value; let/set can't since 0.16 #}
{% if cond %}
{% let val = "a" %}
{% else %}
{% let val = "b" %}
{% endif %}
{{ val }}
- Shadowing is allowed, same as Rust.
decl(aliasdeclare) is the only valueless declaration since 0.16. Bare{% let x %}/{% set x %}with no=now starts a let/set block instead (below).- let/set blocks (0.16): capture a rendered block into a string variable:
{% let heading %}{{ title }} on {{ site }}{% endlet %} {{ heading }} - Compound assignment uses the
{% mut %}tag (0.16), not barelet:{% mut counter += i %}. Every Rust compound operator (+=,-=,*=, …) works; the target must be amutbinding. - Do not prefix variables with
__askama, name onecaller(reserved since 0.15), or use Rust keywords.
Filter Blocks
Apply one or more filters to a whole block:
{% filter lower | capitalize %}
{{ t }} / HELLO / {{ u }}
{% endfilter %}
7. Whitespace Control: Three Operators, Not One
| Operator | Name | Effect |
|---|---|---|
- |
suppress | Remove all whitespace on that side |
~ |
minimize | Collapse to a single space/newline |
+ |
preserve | Keep whitespace as-is (overrides config) |
Usage: put right after {%/{{ or right before %}/}}.
<div>
{%- if x %} {# strip before this tag #}
<p>{{ x }}</p>
{%- endif %}
</div>
{% for x in xs ~%} {# minimize trailing whitespace #}
{{ x }}
{%~ endfor %}
Global default comes from askama.toml's whitespace = "preserve" | "suppress" | "minimize". Inline operators override globals. When two inline controls point at the same span, Suppress always wins; between Minimize and Preserve, whichever operator sits on the tag AFTER the gap wins (positional, not a fixed priority). The book's flat Suppress > Minimize > Preserve table is wrong vs 0.16.0 for the Minimize/Preserve case.
Whitespace-control operators (-/+/~) on {% extends %} are accepted but a no-op, not a compile error. The child's top-level content is dropped anyway, so a trim has nothing to act on. (The book still describes this as "rejected"; that's stale vs 0.16.0.)
8. Comments
Nested block comments are supported:
{# outer {# nested #} still in outer #}
9. Inheritance (extends / block / super)
base.html:
<title>{% block title %}{{ title }} · Site{% endblock %}</title>
<main>{% block content %}<p>default</p>{% endblock %}</main>
page.html:
{% extends "base.html" %}
{% block title %}Index{% endblock %}
{% block content %}
{{ super() }} {# inject the parent block's contents #}
<h1>Hello!</h1>
{% endblock %}
Rules:
- Multi-layer inheritance is supported.
- Blocks inside
if/else/for: the book forbids this, but 0.16.0 compiles it and inheritance overrides correctly (verified empirically). Top-level or block-nested stays the safe form. - The extending template's top-level content outside blocks is ignored.
- A base template must define at least one block for inheritance to work.
- Askama looks for the extended template relative to the extending one first, then relative to the config'd template dirs.
- Duplicate
{% block %}names in the same template are a hard compile error since 0.16, previously only a warning (see §15).
Single-Block & Per-Block Sub-Templates
block / blocks (see §4): block = "content" renders one block (fields outside it not required, good for HTMX partials); blocks = ["title", "content"] generates as_<block>() accessor methods on the struct itself, e.g. page.as_title().render() / page.as_content().render() (no new struct types).
10. Includes, Macros, and Composition
{% include %}
{% for item in items %}
{% include "item.html" %}
{% endfor %}
- Path must be a string literal (known at compile time).
- The included template has full access to the caller's scope, including loop locals.
- Lookup: relative to including template first, then template dirs.
Macros
Define with {% macro name(args) %}...{% endmacro %}, call with {% call name(args) %}:
{% macro heading(arg) %}
<h1>{{ arg }}</h1>
{% endmacro %}
{% call heading("Title") %}
- Expression-call syntax (0.15): invoke a macro like a function,
{{ heading("Title") }}(named args allowed too). Only works for macros that don't require acallerbody. - Macro args can carry type annotations and default-value generics (0.15/0.16).
- Optional named endmacro:
{% endmacro heading %} - Named arguments (must come AFTER positional):
{% macro h(arg, bold) %}<h1>{{arg}}<b>{{bold}}</b></h1>{% endmacro %} {% call h(bold="x", arg="y") %}
Importing Macros from Another File
{%- import "macros.html" as m -%}
{% call m::heading("hi") %}
Call-Block Syntax (Macros with Inner Content)
{% macro card() %}
<div class="card">{{ caller() }}</div>
{% endmacro %}
{% call card() %}
<p>Body passed into the macro.</p>
{% endcall %}
caller() inside the macro renders the block passed to {% call %}...{% endcall %}. To pass arguments, the call-block declares them up front: {% call(user) dump_users(list) %}...{% endcall %} makes the macro's caller(user) render the body once per invocation. Guard an optional body with {% if caller is defined %}. caller is a reserved variable name since 0.15.
Render-in-Place (Nested Template Structs)
Embed a Template-derived type as a field; it auto-renders via its Display impl:
#[derive(Template)]
#[template(source = "Section 1: {{ s1 }}", ext = "txt")]
struct Outer { s1: Inner }
#[derive(Template)]
#[template(source = "A={{ a }}", ext = "txt")]
struct Inner { a: String }
Recursive Rendering
Self-include does NOT work. Do recursion with an explicit .render() call:
#[derive(Template)]
#[template(source = r#"
{{ name }} {
{% for child in children %}
{{ child.render()? }}
{% endfor %}
}
"#, ext = "txt", escape = "none")]
struct Item<'a> {
name: &'a str,
children: &'a [Item<'a>],
}
(Prefer a custom iterator + plain loop when possible.)
11. Configuration: askama.toml
Place at crate root (next to Cargo.toml). All sections are optional.
[general]
dirs = ["templates"] # default; globs OK (0.16): ["templates/*"], ["templates/**"]
whitespace = "preserve" # or "suppress" | "minimize"
# Custom delimiters (e.g. to avoid collisions with LaTeX or Vue)
[[syntax]]
name = "vue"
block_start = "[%"
block_end = "%]"
expr_start = "[["
expr_end = "]]"
comment_start = "[#"
comment_end = "#]"
# Custom escaper: apply askama::filters::Text (no escaping) to .js files
[[escaper]]
path = "askama::filters::Text"
extensions = ["js"]
Use a custom syntax per-template with #[template(path = "...", syntax = "vue")].
Default escaping extensions:
- HTML escaper:
askama,html,htm,j2,jinja,jinja2,rinja,svg,xml - Text (no escape):
md,yml,none,txt(and the empty extension)
Escape modes override via #[template(escape = "html")] or escape = "none".
12. Auto-Escaping
Askama escapes <, >, &, ", ' per OWASP recommendations when the extension implies HTML.
Per-expression bypass:
{{ value | safe }}: don't escape this value{{ value | escape }}or{{ value | e }}: force escape in an unescaped context
13. Built-in Filters
All filters use value | filter_name(args). Named arguments are supported: {{ count | pluralize(plural = "gies") }}.
Always Available
| Filter | Purpose |
|---|---|
capitalize |
First char upper, rest lower |
center |
Center in a field of given width |
escape / e |
Force HTML escape |
filesizeformat |
Bytes -> "1.4 KB" |
fmt(fmtstr) |
Apply Rust format string |
format(fmtstr, ...) |
Like format!() |
indent(n) / indent(prefix) |
Indent each line |
join(sep) |
Join iterable into string |
linebreaks |
Plain text -> <p>/<br> |
linebreaksbr |
Newlines -> <br> |
lower / lowercase |
Lowercase |
paragraphbreaks |
Only \n\n -> <p> |
pluralize(singular="", plural="s") |
Suffix based on ±1 |
reject / reject_with |
Filter iterator |
safe |
Mark as HTML-safe |
title / titlecase |
Title Case |
trim |
Strip whitespace |
truncate(n) |
Truncate with "…" |
unique |
Dedup iterator (requires std) |
upper / uppercase |
Uppercase |
wordcount |
Count words |
assigned_or(fallback) |
Fallback if the value equals its type default (0.15) |
defined_or(fallback) |
Fallback if the identifier is undefined; LHS must be an identifier (0.15) |
default(val[, bool]) |
Jinja-compat: acts as defined_or, or as assigned_or when 2nd arg is true. Prefer the two above (0.15) |
Feature-Gated
| Filter | Feature flag |
|---|---|
json (indent arg selects pretty: `{{ value |
json(2) }}`) |
urlencode (does NOT encode /) |
urlencode |
urlencode_strict (encodes /) |
urlencode |
Add to Cargo.toml: askama = { version = "0.16", features = ["serde_json", "urlencode"] }
Filters Removed Since Older Askama
humansize: always available now, no flag neededmarkdown: removed, usecomrakdirectlyserde-yaml: removed, useyaml-rust2directlyserde-jsonrenamed toserde_json
14. Custom Filters
Since 0.15 a custom filter is a plain fn annotated with #[askama::filter_fn]. Put it in a module named filters in scope of the #[derive(Template)] struct, or call it via an explicit path ({{ x | mymod::myfilter }}).
Mandatory signature: first arg is the piped-in value (any type; impl Display or a generic bound is typical, accepting owned and borrowed), second is &dyn askama::Values (askama's runtime-values env). Returns askama::Result<T>. In a chain only the last filter's T must be Display; earlier ones may return any T.
use askama::Template;
#[derive(Template)]
#[template(source = "{{ s | shout }}", ext = "txt")]
struct Msg<'a> { s: &'a str }
mod filters {
#[askama::filter_fn]
pub fn shout<T: std::fmt::Display>(
s: T,
_env: &dyn askama::Values,
) -> askama::Result<String> {
Ok(s.to_string().to_uppercase() + "!")
}
}
Extra arguments come after the two mandatory ones:
- Required args are just more params:
fn repeat(s: impl Display, _: &dyn Values, n: usize)invoked as{{ s | repeat(4) }}. - Optional args (must follow every required one) declare their default with the
#[optional(...)]attribute:#[askama::filter_fn] pub fn f( value: impl Display, _env: &dyn askama::Values, #[optional(None)] a: Option<&str>, // omitted -> None #[optional("hi")] b: &str, // omitted -> "hi" ) -> askama::Result<String> { /* ... */ } - Named-argument invocation works (named must follow positional):
{{ x | f(b = "yo") }}. Lifetimes on the fn are allowed since 0.15.1,wherebounds since 0.15.2.
Notes:
- Built-in filter names take precedence over custom ones; be explicit with
{{ x | filters::myfilter }}if you shadow one. - Migrating from ≤0.14: the old bare-fn form (no attribute) no longer compiles. Add
#[askama::filter_fn]; it's what unlocked the named/optional args the old form couldn't express. - Filter fns and
render()/render_into()are synchronous only. No async support; anasync fndoesn't satisfy the#[askama::filter_fn]signature.
15. Gotchas and Footguns
askama_axum/askama_actix/ etc. are gone: use#[derive(WebTemplate)](askama_web) or manual.render()+Html(...).|,&,^are not bitwise in templates: usebitor,bitand,xor.- Self-include doesn't work: recurse via an embedded child struct (Display) or explicit
{{ child.render()? }}. - Blocks inside
if/for: book forbids it, 0.16.0 compiles it (§9); top-level/block-nested is safe. - Whitespace control on
{% extends %}is a silent no-op, not a compile error (§7). - Variable names starting with
__askama, the reservedcaller, or Rust keywords are banned. - A method returning
selfinfinite-loops via Display; don't write{{ self }}. - Rust macro calls don't infer field names (§5):
{{ my_macro!(field) }}seesfield, notself.field. - Prefer
.render()/.render_into()over.to_string()(2-3x slower). ~is concat (with spaces) vs. whitespace-minimize (no spaces): keep spaces around concat.- Auto-escape depends on extension (
.htmlescaped,.txtnot); inlinesourceneeds explicitext/escape. - Rinja is dead:
rinja/rinja_axum/rinja_derive->askama/askama_web(see intro). - Custom filters need
#[askama::filter_fn]since 0.15; a barefninmod filtersno longer compiles (§14). - 0.16 upgrade breakage: valueless
{% let x %}/{% set x %}now opens a block (use{% decl x %}); compound assignment moved to{% mut %}; duplicate block names are now a hard compile error (was a warning). path/sourcetake string literals only (§4), same as{% include %}(§10). No picking the template from a variable or computed string at runtime.- Filter fns and
render()/render_into()are sync only (§14). No async filter or async render path exists.
16. Debugging Tips
- Playground: https://askama.rs has a web playground that shows the generated code for any template.
- Compile errors: Askama's error messages point at the template's
file:line:columnlocation. Lifetime / ownership errors in generated code usually mean a field got moved. Take a reference, or use&in the template. - Book: https://askama.rs for the canonical syntax reference.
- docs.rs: https://docs.rs/askama for API docs and the full filter list.
17. Quick Reference Card
Expressions: {{ expr }} Tags: {% stmt %} Comments: {# ... #}
WS control: {%- strip -%} {%~ min ~%} {%+ keep +%}
Inheritance: {% extends "base.html" %} {% block x %}...{% endblock %} {{ super() }}
Loops: {% for x in xs if cond %}...{% else %}...{% endfor %} loop.{index,index0,first,last,cycle([…])}
Conditionals: {% if %}...{% else if %}...{% else %}...{% endif %}
Match: {% match v %}{% when Some with (x) %}...{% when None %}...{% endmatch %}
Vars: {% let x = y %} {% let mut it = … %} {% set x = 1 %} {% decl x %} {% mut x += 1 %}
Let block: {% let s %}…rendered…{% endlet %} (captures block output into `s`)
Macros: {% macro m(a) %}...{% endmacro %} {% call m(a) %} expr-call: {{ m(a) }} {% call(x) m(a) %}body{% endcall %} inside macro: {{ caller() }} / {{ caller(x) }}
Imports: {% import "m.html" as ns %} {% call ns::m(a) %}
Include: {% include "part.html" %}
Filters: {{ x | lower | trim }} block: {% filter lower %}...{% endfilter %}
Concat: {{ a ~ b ~ c }} (spaces required)
Cast: {{ x as i64 }} (primitives only)
Bitwise: a bitor b, a bitand b, a xor b (NOT |, &, ^)
Crate consts: {{ crate::MY_CONST }}
Struct ctor: {{ Foo { a: 1 }.to_string() }}
Escape: {{ x | safe }} {{ x | e }} or #[template(escape = "none"|"html")]