Kida vs Jinja2 — A Template Engine Built for Parallel Renders

Kida vs Jinja2 — shared state, copy-on-write, static analysis, and when to choose each Python template engine.

Jinja2 is the default. Kida is built for a world where many threads render at once.

flowchart TB subgraph Kida["Kida — copy-on-write"] K1[add_filter] --> K2[New dict] K2 --> K3[Replace env._filters] K3 --> K4[Threads mid-render keep old dict] end subgraph Jinja2["Jinja2 — mutable"] J1[env.filters =] --> J2[Mutate in place] J2 --> J3[All threads share same dict] J3 --> J4[Race under free-threading] end

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.

flowchart LR subgraph Kida["Kida render"] K_T1[Thread 1] --> K_Env[Environment] K_T2[Thread 2] --> K_Env K_Env --> K_Filters["filters (immutable)"] end subgraph Jinja2["Jinja2 render"] J_T1[Thread 1] --> J_Env[Environment] J_T2[Thread 2] --> J_Env J_Env --> J_Filters["filters (shared mutable)"] end

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.

flowchart LR subgraph Kida["Kida"] K1[Template] --> K2[Parse to AST] K2 --> K3[required_context] K2 --> K4[validate_context] K2 --> K5[is_cacheable] K3 --> K6[Pre-render checks] K4 --> K6 K5 --> K6 end subgraph Jinja2["Jinja2"] J1[Template] --> J2[Compile] J2 --> J3[Render] J3 --> J4[UndefinedError at runtime] end

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.