Jinja2 is the default. Kida is built for a world where many threads render at once.
TL;DR
| Kida | Jinja2 | |
|---|---|---|
| Shared state | Copy-on-write | Mutable globals |
| Free-threading | Yes (3.14t) | No |
| Static analysis | Yes | No |
| Syntax | Jinja2-like | Jinja2 |
| Ecosystem | b-stack | Huge |
Shared state and the GIL
Template engines carry shared state: filters, globals, compiled templates, render context, caches.
| Traditional Python (GIL) | Free-threaded Python (3.14t) | |
|---|---|---|
| Jinja2 | GIL serializes access; add_filter() mutates shared dict |
Race: threads don't take turns |
| Kida | Same behavior | Copy-on-write; no shared mutation |
Adding a filter — side by side
from kida import Environment
env = Environment()
env.add_filter("money", lambda x: f"${x:,.2f}")
# Copy-on-write: replaces _filters dict, doesn't mutate in place
# Threads mid-render keep reading the old dict
from jinja2 import Environment
env = Environment()
env.filters["money"] = lambda x: f"${x:,.2f}"
# Mutates env.filters in place
# Under GIL: fine. Under free-threading: race if threads render concurrently
Kida uses copy-on-write: add_filter creates a new dict and assigns it. Threads mid-render keep reading the old dict. Nobody fights over the same state.
Template syntax — production example with null coalescing
Same quick-links section from Bengal's API reference theme. Kida has ?. (optional chaining) and ?? (null coalescing) built in. Jinja2 does not — you use | default() and defensive conditionals.
{% extends "base.html" %}
{% block title %}{{ page.title | default(site.title) }}{% end %}
{% block content %}
<h1>{{ page.title ?? 'API Reference' }}</h1>
{% set quick_links = params?.quick_links ?? [] %}
{% for link in quick_links %}
{% if link and (link?.href or link?.url) %}
<a href="{{ link?.href ?? link?.url ?? '#' }}">
{{ link?.title ?? 'Link' }}
</a>
{% end %}
{% end %}
{% end %}
{% extends "base.html" %}
{% block title %}{{ page.title | default(site.title) }}{% endblock %}
{% block content %}
<h1>{{ page.title | default('API Reference') }}</h1>
{% set quick_links = (params.quick_links if params else []) or [] %}
{% for link in quick_links %}
{% if link and (link.href or link.url) %}
<a href="{{ (link.href or link.url) or '#' }}">
{{ link.title | default('Link') }}
</a>
{% endif %}
{% endfor %}
{% endblock %}
Differences:
| Feature | Kida | Jinja2 |
|---|---|---|
| Null coalescing | page.title ?? 'Fallback' |
page.title | default('Fallback') |
| Optional chaining | params?.quick_links |
params.quick_links if params else none |
| Block end | {% end %} (all blocks) |
{% endblock %}, {% endfor %}, {% endif %} |
| Safe attr access | link?.href ?? link?.url |
link.href or link.url (fails if link is None) |
Kida: ?. and ?? avoid AttributeError when params or link is missing.
Jinja2: Use if params and params.quick_links and hope you didn't miss a branch.
Static analysis — Kida only
template = env.get_template("page.html")
template.required_context() # frozenset({'page', 'site'})
template.validate_context(ctx) # ['site'] — catch missing before render
template.is_cacheable("nav") # True — block depends only on site.menu
template = env.get_template("page.html")
# No equivalent. You discover missing variables at render time:
# UndefinedError: 'site' is undefined
Kida compiles to Python AST, not source strings. That enables analysis without rendering. See Static Analysis for Templates for how Bengal uses this for block caching.
Where each shines (and stumbles)
Shines: Copy-on-write, no shared mutation. Static analysis (required_context, is_cacheable). Free-threading safe. Integrates with Bengal and Chirp.
Stumbles: Newer. Smaller ecosystem. {% end %} instead of {% endblock %} — small migration cost. No Jinja2 extensions.
Shines: Mature. Huge ecosystem. Flask, Pelican, MkDocs, Ansible. Everywhere. {% endblock %} is familiar.
Stumbles: Mutable globals. No static analysis. Under free-threading, add_filter() races with concurrent renders. No required_context() or is_cacheable().
| Shines | Stumbles | |
|---|---|---|
| Kida | Copy-on-write, static analysis, free-threading safe | Newer, smaller ecosystem, {% end %} migration |
| Jinja2 | Mature, huge ecosystem, everywhere | Mutable globals, no static analysis, races under free-threading |
When to use Jinja2
| Use case | Use Jinja2 when |
|---|---|
| Python version | Traditional Python (GIL enabled) |
| Ecosystem | You need extensions, Flask, Pelican, MkDocs |
| Priorities | You don't care about parallel rendering or static analysis |
Jinja2 is mature, well-documented, and everywhere. For most projects, it is the right choice.
When to use Kida
| Use case | Use Kida when |
|---|---|
| Python version | Python 3.14t, lock-free parallel rendering |
| Validation | You need pre-render validation (required context, cache scope) |
| Stack | You're building with Bengal or Chirp |
Kida is part of the vertical stack. If you're not in that stack, Jinja2 is usually the better fit.