Knowing the compiled form explains performance, scoping rules and error tracebacks.
Jinja doesn't interpret templates; it compiles them to Python. The pipeline:
1. The lexer splits source into tokens, using the configured delimiters (you can change {{ and {% via Environment options). 2. The parser builds an AST of jinja2.nodes (Output, For, If, Name, Filter, ...). env.parse(source) returns it, which is what meta.find_undeclared_variables analyzes. 3. The code generator turns the AST into Python source. You can read it with env.compile(source, raw=True). 4. Python compiles that source to a code object, cached in memory and optionally by a bytecode cache.
In the generated module, the template body becomes a generator function root(context, ...) that yields strings, and each block becomes its own function in a blocks dict. That is why inheritance is cheap: the child's block functions replace the parent's when the parent's root runs. Macros become Python functions returning Markup.
Variables are resolved from the Context, a layered mapping of template variables, then globals. Lookups are cached in local Python variables, so {% set %} inside a loop creates a new local in the loop's scope, the reason assignments don't leak out of loops and blocks.
Filter and test names are resolved at compile time, so an unknown filter fails when the template is loaded, while undefined variables only matter at render time.
Tracebacks point at template lines because Jinja keeps a debug line mapping and rewrites the traceback frames.
The generated code is an implementation detail and changes between versions; inspect it to learn, never depend on it.
Example
from jinja2 import Environment
from jinja2.exceptions import TemplateAssertionError
env = Environment()
src = "{% for x in items %}{{ x|upper }}{% endfor %}"
print(env.parse(src))
# Template(body=[For(target=Name(name='x', ctx='store'), iter=Name(name='items', ...
code = env.compile(src, raw=True)
print([line for line in code.splitlines() if line.startswith("def ")])
# ['def root(context, missing=missing, environment=environment):']
print("t_1 = environment.filters['upper']" in code) # True: resolved up front
try:
env.from_string("{{ x|no_such_filter }}") # fails at compile time
except TemplateAssertionError as e:
print(e) # No filter named 'no_such_filter'.
t = env.from_string("{{ x }}")
print(t.render(x=1), list(t.generate(x=2))) # 1 ['2']Remember
- Templates go lexer -> parser AST -> generated Python -> bytecode
- Each template is a root() generator; blocks and macros become functions
- Filters are resolved at compile time, variables at render time
Watch out for
- Relying on the generated Python code, which is internal and changes between releases.