Skip to content

Server-rendered HTML with Jinja2 — engine setup, the global-singleton design, autoescaping, context injection, and the caching and reload settings that matter in production.

sillo provides a powerful templating system built on top of Jinja2, offering features like template inheritance, context management, custom filters, and more.

Before using templating features, you need to install the required dependencies:

Terminal window
uv add "sillo[templating]"
from sillo import silloApp
from sillo.templating import render,TemplateEngine
app = silloApp()
engine = TemplateEngine()
engine.setup_environment()
@app.get("/")
async def home(request, response):
return await render("home.html", {"title": "Welcome"}, request=request)

There are several ways to customize the templating system:

The simplest way is to set template options in your app configuration:

from sillo.templating import TemplateConfig
template_config = TemplateConfig(
template_dir = "templates"
)
engine.setup_environment(template_config)

You can update configuration at runtime:

from sillo.templating import TemplateEngine
from pathlib import Path
engine = TemplateEngine()
engine.setup_environment()
engine.env.filters["custom"] = my_custom_filter
# Add new globals
engine.env.globals.update({
"api_version": "2.0",
"debug": True
})
engine.config.template_dir = Path("new_templates")
engine.setup_environment(engine.config)

The TemplateConfig class supports the following options:

OptionTypeDefaultDescription
template_dirstr/Path”templates”Template directory path
cache_sizeint100Maximum templates to cache
auto_reloadboolTrueReload changed templates
encodingstr”utf-8”Template file encoding
enable_asyncboolTrueEnable async rendering
trim_blocksboolTrueStrip first newline after block
lstrip_blocksboolTrueStrip leading spaces and tabs
custom_filtersDict[str, callable]{}Custom template filters
custom_globalsDict[str, Any]{}Global template variables

Base template (base.html):

<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
</head>
<body>
<nav>{% block nav %}{% endblock %}</nav>
<main>{% block content %}{% endblock %}</main>
<footer>{% block footer %}{% endblock %}</footer>
</body>
</html>

Child template:

{% extends "base.html" %} {% block title %}Welcome{% endblock %} {% block
content %}
<h1>{{ title }}</h1>
{{ content }} {% endblock %}

Add global and request-specific context to your templates:

from sillo.templating.middleware import template_context
async def user_context(request):
return {
"user": await get_current_user(request),
"messages": await get_flash_messages(request)
}
app.use(template_context(
default_context={
"version": "1.0.0",
"nav_links": [...]
},
context_processor=user_context
))

The templating system includes several utility functions:

registering the utilities as filters
from sillo.templating import TemplateConfig
from sillo.templating.utils import (
format_datetime,
merge_dicts,
static_hash,
truncate,
)
config = TemplateConfig(
custom_filters={"truncate": truncate, "format_datetime": format_datetime},
custom_globals={"static_hash": static_hash, "merge_dicts": merge_dicts},
)
using them in a template
{{ long_text|truncate(100) }}
{{ date|format_datetime("%Y-%m-%d") }}
<link rel="stylesheet" href="/static/style.css?v={{ static_hash('static/style.css') }}">
  1. Template Organization

    • Keep templates in a dedicated directory
    • Use meaningful names and subdirectories
    • Follow a consistent naming convention
  2. Context Management

    • Use middleware for global context
    • Keep context processors focused and lightweight
    • Cache expensive context operations
  3. Performance

    • Enable template caching in production
    • Use async rendering for I/O operations
    • Minimize template complexity
  4. Security

    • HTML escaping is enabled by default
    • Use |safe filter carefully
    • Validate user input before rendering
async def render(
template_name: str,
context: Dict[str, Any] = None,
status_code: int = 200,
headers: Dict[str, str] = None,
request: Request = None,
**kwargs
) -> Response
def template_context(
default_context: Optional[Dict[str, Any]] = None,
context_processor: Optional[
Callable[[Request], Awaitable[Dict[str, Any]]]
] = None
) -> TemplateContextMiddleware
def truncate(text: str, length: int = 100, suffix: str = "...") -> str
def format_datetime(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str
def static_hash(filepath: str) -> str
def merge_dicts(*dicts: Dict[str, Any]) -> Dict[str, Any]

This guide covers advanced features and patterns for the sillo templating system.

Create and register custom template filters:

from sillo.templating import TemplateConfig
def markdown_to_html(text: str) -> str:
import markdown
return markdown.markdown(text)
def currency(value: float, symbol: str = "$") -> str:
return f"{symbol}{value:,.2f}"
config = TemplateConfig(
custom_filters={
"markdown": markdown_to_html,
"currency": currency
}
)

Usage in templates:

{{ post.content|markdown }} {{ product.price|currency("€") }}

Create reusable template components:

{# macros/forms.html #} {% macro input(name, value='', type='text', label='') %}
<div class="form-group">
{% if label %}
<label for="{{ name }}">{{ label }}</label>
{% endif %}
<input
type="{{ type }}"
name="{{ name }}"
value="{{ value }}"
id="{{ name }}"
/>
</div>
{% endmacro %} {# Usage in templates #} {% from "macros/forms.html" import input
%}
<form method="post">
{{ input('username', label='Username') }} {{ input('password',
type='password', label='Password') }}
</form>

Create async template functions for database queries or API calls:

from sillo.templating import TemplateConfig
from functools import partial
async def get_user_posts(user_id: int):
# Async database query
return await db.query("SELECT * FROM posts WHERE user_id = $1", user_id)
config = TemplateConfig(
custom_globals={
"get_posts": get_user_posts
}
)

Usage in templates:

{% set posts = await get_posts(user.id) %} {% for post in posts %}
<article>{{ post.title }}</article>
{% endfor %}

Advanced context processor patterns:

from typing import Dict, Any
from sillo.templating.middleware import template_context
# Use your preferred caching library for memoization
class ContextBuilder:
def __init__(self):
self.processors = []
def add(self, processor):
self.processors.append(processor)
return self
async def build(self, request) -> Dict[str, Any]:
context = {}
for proc in self.processors:
context.update(await proc(request))
return context
# Optional: add caching with your preferred library
async def get_site_stats(request):
return {
"total_users": await db.count("users"),
"total_posts": await db.count("posts")
}
async def get_user_data(request):
if user := await get_current_user(request):
return {
"user": user,
"notifications": await get_user_notifications(user.id)
}
return {}
# Combine processors
context_builder = (
ContextBuilder()
.add(get_site_stats)
.add(get_user_data)
)
app.use(template_context(
context_processor=context_builder.build
))
from datetime import datetime, timedelta
from sillo.templating import TemplateConfig
_cache = {}
def cache_get(key: str):
item = _cache.get(key)
if not item:
return None
value, expires_at = item
if datetime.utcnow() > expires_at:
_cache.pop(key, None)
return None
return value
def cache_set(key: str, value, ttl: int = 300):
_cache[key] = (value, datetime.utcnow() + timedelta(seconds=ttl))
config = TemplateConfig(
custom_globals={
"cache_get": cache_get,
"cache_set": cache_set,
}
)

Usage in templates:

{% set cache_key = 'sidebar_' + user.id %}
{% set cached = cache_get(cache_key) %}
{% if cached %}
{{ cached }}
{% else %}
{% set content %}
<aside>...</aside>
{% endset %}
{{ cache_set(cache_key, content, ttl=300) }}
{{ content }}
{% endif %}

Custom error templates and handling:

from sillo.templating import render
from jinja2 import TemplateError
@app.add_exception_handler(404)
async def not_found(request, response, exc):
return await render(
"errors/404.html",
{"path": request.url.path},
status_code=404
)
@app.add_exception_handler(TemplateError)
async def template_error(request, response, exc):
return await render(
"errors/template.html",
{
"error": str(exc),
"template": exc.template_name,
"lineno": exc.lineno
},
status_code=500
)

Write tests for your templates:

import pytest
from sillo.templating import TemplateEngine, TemplateConfig
from sillo.testclient import TestClient
@pytest.fixture
def template_engine():
config = TemplateConfig(template_dir="tests/templates")
engine = TemplateEngine()
engine.setup_environment(config)
return engine
async def test_template_rendering(template_engine):
content = await template_engine.render(
"welcome.html",
{"name": "User"}
)
assert "Welcome, User!" in content
async def test_template_filters(template_engine):
content = await template_engine.render(
"product.html",
{"price": 99.99}
)
assert "€99.99" in content
async def test_context_middleware(client: TestClient):
response = await client.get("/")
assert response.status_code == 200
assert "Welcome, Test User!" in response.text

TemplateEngine is not per-application. setup_environment() assigns the instance to a module-level global in sillo.templating, and the render() function reads that global:

sillo/templating/__init__.py
engine: Union["TemplateEngine", None] = None
class TemplateEngine:
def setup_environment(self, config=TemplateConfig()):
global engine
...
engine = self

Three things follow from that design.

render() fails until setup runs. Calling it first raises NotImplementedError: Template Engine Has not been set. Call setup_environment() at import time or in a startup hook, before any request can arrive.

There is one engine per process. Two applications in one process share it, and the second setup_environment() call silently replaces the first — including its template directory. If you mount two apps that both render templates, they must share a template root or use separate processes.

Reconfiguring is global and immediate. engine.setup_environment(new_config) swaps the environment for every in-flight and future render. That is fine at startup and a race in production.

The environment is built with select_autoescape(["html", "xml"]). Variables rendered into .html and .xml templates are escaped; variables in any other extension are not.

escaped, because the file is .html
<p>{{ user_supplied }}</p>
NOT escaped — a .txt or .j2 template
Hello {{ user_supplied }}

That is correct behaviour for plain-text output and a trap when a template is named email.j2 or page.tmpl but produces HTML. Name HTML templates .html, always.

Never reach for |safe on anything a user can influence. It disables escaping for that expression and is the standard way cross-site scripting gets into an otherwise careful codebase. When you genuinely need to render stored HTML — a rich-text field, a Markdown render — sanitize it with a real parser first. See the HTML helpers guide for why the built-in sanitize_html is not sufficient for that job.

render(..., request=request) populates three variables automatically, each with setdefault so your own value always wins:

VariableSourcePresent when
requestthe request objectrequest= is passed
url_forrequest.base_app.url_forthe app exposes it
csrf_tokenrequest.state.csrf_tokenCSRF middleware has run

It then merges request.state.template_context, which is what TemplateContextMiddleware populates. The merge happens after setdefault, so middleware context overwrites the per-call context — worth knowing if a variable is mysteriously not what you passed.

global context for every template
from sillo.templating.middleware import template_context
async def user_context(request):
return {"current_user": await get_current_user(request)}
app.use(template_context(
default_context={"site_name": "Example", "version": "2.1.0"},
context_processor=user_context,
))

The processor runs on every request, including ones that render nothing. Keep it cheap: a database query in a context processor is a query added to every static-file request that passes through the middleware stack.

Forgetting request=request is the single most common templating bug. Without it there is no csrf_token, so form posts start failing CSRF validation, and no url_for, so link generation raises UndefinedError.

Two config values behave very differently in development and production.

auto_reload=True stats every template file on every render to detect changes. That is what makes editing a template take effect without a restart, and it is a filesystem call per render per template — including every {% include %} and {% extends %}. Turn it off in production.

cache_size=100 caps how many compiled templates are held. An application with more than a hundred templates will evict and recompile constantly at the default. Set it above your template count, or to -1 for an unbounded cache, which is safe because the number of templates is fixed at deploy time.

a production configuration
import os
is_dev = os.getenv("ENV") == "development"
config = TemplateConfig(
template_dir="templates",
auto_reload=is_dev,
cache_size=-1 if not is_dev else 100,
enable_async=True,
)
engine.setup_environment(config)

enable_async=True compiles templates for render_async, which is what lets a template await a coroutine in an expression. Leave it on — sillo’s render() calls render_async when it is set, and the synchronous fallback blocks the event loop for the duration of the render.

Do not call render() before setup_environment(). It raises NotImplementedError.

Do not run two apps with different template roots in one process. The engine is a module-level global.

Do not give an HTML template a non-.html extension. Autoescaping is selected by suffix.

Do not use |safe on user-influenced values. That is the XSS.

Do not forget request=request. You lose csrf_token and url_for.

Do not query the database in a context processor without checking which requests it runs for.

Do not leave auto_reload=True in production. It stats files on every render.

Do not leave cache_size below your template count. You will recompile on every request.

Template compilation is expensive; rendering a compiled template is not. The cache is what stands between you and paying compilation on every request, so sizing it correctly matters more than any other setting here.

trim_blocks and lstrip_blocks are both on by default. They affect whitespace in the output, not speed — but they change rendered output, so turning them off later will alter every page.

Inheritance chains are resolved per render. A template that extends a base that includes four partials is five cache lookups, which is cheap when cached and five compilations when not.

For pages that are expensive to build and rarely change, cache the rendered HTML rather than tuning the template engine. See Cache.