A drop-in replacement for Django's template engine, 100% compatible including custom tags and filters, but much faster
Project description
django-template-compiler
A drop-in replacement for Django's template engine, 100% compatible including custom tags and filters, but much faster.
Status: pre-alpha, but substantially complete. Every parseable template compiles: dedicated code generation for the core template language (variables, filters, control flow, inheritance, simple_tag/inclusion_tag, container tags), with anything else — arbitrary third-party tags included — running as-is against the live context. dtc passes Django's own template test suite (Django 4.2–5.2) in CI, plus a differential fuzzer. Typical speedups: 1.6–2.1x on template-bound rendering, with a ~1.0x floor when a template is dominated by bridged tags. Not yet exercised by production traffic — try it and report.
Two behaviors worth knowing:
DEBUG=Truedisables compilation (per engine): Django's debug error page and exception annotation need the interpreted render path. Production configs get the compiled path; development keeps perfect debugging.- Django test instrumentation is honored: when
setup_test_environment()(the test runner /assertTemplateUsed) patches template rendering, dtc detects the patch and routes through it, so thetemplate_renderedsignal fires exactly as with stock Django.
How it works
Templates are parsed with Django's own lexer and parser, then compiled to Python code — a {% for %} loop becomes a real Python for loop, variable lookups become direct attribute/key access. Anything the compiler can't handle yet (including arbitrary custom tags) falls back to Django's interpreted render path, so output is always exactly what Django would produce.
Benchmarks
benchmarks/bench.py, Python 3.11, Django 5.2 (µs per render; higher speedup is better):
| scenario | django | dtc | speedup |
|---|---|---|---|
| 40 plain variables | 52.2 | 28.6 | 1.8x |
| 100-row loop | 165.7 | 73.2 | 2.3x |
100-row loop with forloop.counter |
679.4 | 126.9 | 5.4x |
| 50×4 table (nested loop + if) | 516.3 | 271.9 | 1.9x |
| with/if scopes | 206.8 | 91.2 | 2.3x |
| spaceless-wrapped table | 248.2 | 114.6 | 2.2x |
| inheritance + include in loop | 151.4 | 93.5 | 1.6x |
| bridged unknown tag (worst case) | 26.3 | 21.0 | 1.3x |
For reference, Jinja2 renders the table scenario in ~80µs — dtc closes about half the gap to Jinja2 while producing byte-identical Django output. The remaining distance is the price of Django's semantics themselves (silent variable failures, callable auto-invocation, the context stack), which dtc preserves exactly and Jinja2 deliberately dropped.
Installation
pip install django-template-compiler
The import name is dtc.
Usage
Change one line in your TEMPLATES setting:
TEMPLATES = [
{
"BACKEND": "dtc.backend.DTCTemplates", # was django.template.backends.django.DjangoTemplates
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
# all DjangoTemplates options work unchanged
"context_processors": [...],
},
},
]
Everything else — template syntax, custom tag libraries, context processors, {% load %}, filters — works unchanged.
Cold starts and the disk cache
Compiling costs roughly 9x Django's parse per template, paid once per process. If your deployment restarts processes often (serverless, aggressive autoscaling), enable the disk cache, which persists compiled code objects across processes and cuts that overhead by ~70%:
"OPTIONS": {
"dtc_disk_cache": True, # ~/.cache/dtc/..., or pass an explicit path
},
Cache entries are keyed by a hash of the generated code, so stale entries are impossible by construction; corrupt or version-mismatched entries are silently recompiled. Point it only at a directory you trust — cached code is executed.
Declaring custom tags context-safe
A custom tag without dedicated codegen renders through its own render() against the live context, which is always exact — but because the compiler can't see what that render() does, one such tag disables the read optimizations around it: the flattened read snapshot (template-wide), scope locals (in every enclosing {% for %}/{% with %}), and compiled-function sharing across template instances (which matters without a cached loader). takes_context simple/inclusion tags pay the first two as well.
Most tags never write the context. If yours is one of them, declare it:
class BreadcrumbNode(Node):
dtc_context_safe = True # stock Django ignores this; dtc keeps its
... # optimizations around the tag
# takes_context tags declare the *function*:
@register.simple_tag(takes_context=True)
def current_section(context):
return context.get("section", "home")
current_section.dtc_context_safe = True
# third-party tags you can't edit, e.g. in settings or AppConfig.ready():
import dtc
dtc.declare_safe(SomeThirdPartyNode)
The declaration is a promise about every render() call: the context stack and its mappings are left exactly as found (balanced push/pop inside is fine); no state keyed on the node's identity (Django's CycleNode/IfChangedNode pattern); behavior depends only on the parsed source. Reading the context is always fine, as is setting context.autoescape. A container tag may render nested writers freely, provided every nodelist it renders is listed in the standard child_nodelists attribute — the compiler analyzes those children itself; a rendered-but-unlisted nodelist is the one thing that can silently break output. Subclasses inherit the declaration with the render() it describes (dtc_context_safe = False opts back out). See help(dtc.declare_safe) for the precise contract.
Tags that do write the context can declare what they write instead, as long as the target names are fixed at parse time — the common capture/setter shape:
class CaptureNode(Node):
# names the instance attributes holding the written context keys
dtc_context_writes = ("target",)
def __init__(self, nodelist, target):
self.nodelist = nodelist
self.target = target # {% capture NAME %}...{% endcapture %}
def render(self, context):
context[self.target] = self.nodelist.render(context)
return ""
# or for classes you can't edit:
dtc.declare_writes(SomeVendorSetterNode, "dest")
The compiler routes reads of the declared names through the live context and keeps every optimization on for everything else — including scope locals: if a declared write shadows a {% for %}/{% with %} name, the generated code re-reads that local right after the tag runs. The rest of the contract matches dtc_context_safe; the declared keys may be set only (no deletions), and an attribute holding None means an optional target unused at that site. See help(dtc.declare_writes).
Declared writes may target the normal top-of-stack (context[key] = value) or the root layer (context.dicts[0][key] = value — the pattern used by tags that persist a value across template boundaries, past every scope pop). Root-written names are never served from the read snapshot (it excludes the root layer by design), so they stay exact across template boundaries, includes, and re-writes.
A wrong declaration produces wrong output silently — so verify it: run your test suite with DTC_CHECK_DECLARATIONS=1 and dtc checks every declared render, raising dtc.ContextSafeViolation on any write outside the declaration. (Containers wrapping legitimate writers are skipped by the checker; the source-determinism clause isn't mechanically checkable.)
Tags that just compute a value from their arguments — a formatter, a calculator, a lookup — are better rewritten as @register.simple_tag: those compile natively, declaration-free, with argument resolution inlined.
Limitation: tags that rewrite enclosing context layers
Within a single template, any custom tag is rendered exactly — the compiler disables its read optimizations around every tag it doesn't recognize. Across template boundaries there is one assumption: a tag's context effects that outlive an {% include %}/{% block %}/{% extends %} are either scope-limited (ordinary context[key] = value writes and balanced push/pop, which die with the layers that the include/block machinery pops) or root-layer (context.dicts[0][key] = value, which dtc handles as described above). Every Django built-in and every simple_tag/inclusion_tag satisfies this.
A tag that mutates an intermediate layer of the caller's stack — indexing context.dicts[1], calling Context.set_upward(), deleting keys from enclosing layers, or leaking an unbalanced push() — from inside an included or extended template can produce output that differs from stock Django: the enclosing template was compiled without knowledge of that tag, and its read snapshot or scope locals may serve the pre-mutation value. DTC_CHECK_DECLARATIONS cannot catch this (the tag carries no declaration, and the effect surfaces in a different template than the tag).
If you have such a tag, the supported paths are: write the root layer instead (dicts[0] — fully supported and declarable), write the top of the stack, or confine the mutation to the template that renders the tag. Note that intermediate-layer writes are fragile under stock Django too — what dicts[1] is depends on the stack depth at the call site.
Development
pip install -e .[dev]
pytest
License
BSD 3-Clause. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_template_compiler-0.0.1.tar.gz.
File metadata
- Download URL: django_template_compiler-0.0.1.tar.gz
- Upload date:
- Size: 68.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8e6c76fd9e0a8d486a2a9dff98b4e86f1f627444ed39bc62b24ec5bdc23555fd
|
|
| MD5 |
61685ec86511f2340744ab863822d8d0
|
|
| BLAKE2b-256 |
dc452035b51cf88b3b3482ab7c8e302d4920198df77bc5d68c13890a45bd5850
|
File details
Details for the file django_template_compiler-0.0.1-py3-none-any.whl.
File metadata
- Download URL: django_template_compiler-0.0.1-py3-none-any.whl
- Upload date:
- Size: 36.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
db6d8d6fe0be557ac8e3441e68c79482ab78f612dbf58465f4f5213264656fb9
|
|
| MD5 |
525dd2b96a9ef0b50b085b68a94935cb
|
|
| BLAKE2b-256 |
adb8a5efe66385bbe5310e79372161c996986f6ac4fe746c20280b008f26c0fb
|